Script :: Toggle a prefix character in selected lines

Bike may at some point acquire row type attributes like note etc, and a style-sheet mechanism for making row type differences visible, but in the meanwhile:

here is a script for toggling single character prefixes( like >, for example) at the start of each selected line:

toggledPrefix


To test in Script Editor.app,

  • set the language selector at top left to JavaScript (rather than AppleScript)
  • copy and past the whole of the script behind the expansion triangle below,
  • select some lines in Bike
  • and click Run.
Expand disclosure triangle to view JS Source
(() => {
    "use strict";

    // Toggle a given prefix character in selected lines
    // of Bike 1.2

    // Rob Trew @2022
    // Ver 0.01

    // --------------------- OPTION ----------------------
    // Which single-character prefix to add or clear ?

    const prefixChar = ">";

    // const
    //     prefixChar = Application("Keyboard Maestro Engine")
    //     .getvariable("local_PrefixChar");

    // ------------ PREFIX CHARACTER TOGGLED -------------
    // main :: IO ()
    const main = () => {
        const
            bike = Application("Bike"),
            doc = bike.documents.at(0);

        return doc.exists() ? (() => {
            const
                selectedRows = doc.rows.where({
                    selected: true
                }),
                n = selectedRows.length;

            return Boolean(n) ? (() => {
                const [f, change] = (
                    selectedRows.at(0).name()
                    .startsWith(prefixChar)
                ) ? (
                    [dePrefixed(prefixChar), "CLEARED"]
                ) : [prefixed(prefixChar), "ADDED"];

                return (
                    zipWith(row => s => row.name = s)(
                        selectedRows()
                    )(
                        selectedRows.name().map(f)
                    ),
                    [
                        `${change} '${prefixChar}' prefix`,
                        `in ${n} selected lines.`
                    ]
                    .join("\n")
                );
            })() : "No rows selected in Bike";
        })() : "No documents open in Bike";
    };

    // -------------------- PREFIXES ---------------------

    // prefixed :: Char -> String -> String
    const prefixed = c =>
        s => `${c} ${dePrefixed(c)(s)}`;


    // dePrefixed :: Char -> String -> String
    const dePrefixed = c =>
        s => c === s[0] ? (
            s.slice(" " === s[1] ? 2 : 1)
        ) : s;

    // --------------------- GENERIC ---------------------

    // zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
    const zipWith = f =>
        // A list constructed by zipping with a
        // custom function, rather than with the
        // default tuple constructor.
        xs => ys => xs.map(
            (x, i) => f(x)(ys[i])
        ).slice(
            0, Math.min(xs.length, ys.length)
        );

    // MAIN
    return main();
})();

(You can choose a different single-character prefix by editing the value of the prefixChar string constant at the top of the script)


Using Scripts - Bike


Keyboard Maestro makes it easier to:

  • bind keystrokes to scripts like this, and
  • adjust options (e.g. here, make different copies for different prefixes)
  • get feedback in notifications

BIKE :: Toggle a prefix character in selected lines – Keyboard Maestro Macro

2 Likes

I wanted this just this morning. :slight_smile: Thanks.

1 Like

@complexpoint — can this be easily adapted to TaskPaper?

I would love to be able to toggle an em dash and a space at the beginning of a text line in TaskPaper.

Example:

Test: (original)

— Test: (toggled on)

Test: (toggled off)

You could certainly write something like this for TaskPaper – adaptation perhaps not directly – the Bike version is written for the JXA interface with where/whose clauses etc

I’m working on something else at the moment, but very happy to help at some point if anything is looking like a roadblock.

Rough sketch of basic mapping to TP:

Expand disclosure triangle to view JS source
(() => {
    "use strict";

    // Toggling a prefix in selected TaskPaper lines.

    // Rob Trew @2022
    // Ver 0.05

    // (0.05 ignores empty lines in the selection)

    // --------------------- OPTIONS ---------------------

    const prefix = ">";

    // ---------- TASKPAPER EVALUATION CONTEXT -----------

    // eslint-disable-next-line max-lines-per-function
    const tp3Context = (editor, options) => {
        const
            selection = editor.selection,
            prefixChar = options.prefix.trim();

        // tp3Main :: () -> IO String
        const tp3Main = () => {
            const
                items = selection.selectedItems.filter(
                    x => 0 < x.bodyString.length
                );

            return 0 < items.length ? (
                toggledPrefixes(
                    ...(
                        items[0].bodyString.startsWith(
                            prefixChar
                        )
                    ) ? [
                            dePrefixed(prefixChar),
                            "CLEARED"
                        ] : [
                            prefixed(prefixChar),
                            "ADDED"
                        ]
                )(items)
            ) : "No items selected in TaskPaper";
        };

        // toggledPrefixes :: (String -> String) ->
        // String -> [TaskPaper Item] -> IO String
        const toggledPrefixes = (f, change) =>
            items => {
                const
                    // Anchors for normalised selection
                    // after updates.
                    startLocn = editor
                    .getLocationForItemOffset(
                        selection.startItem, 0
                    ),
                    endItem = selection.endItem;

                return (
                    // Prefixes toggled,
                    editor.outline.groupUndoAndChanges(
                        () => items.forEach(
                            row => row.bodyString = (
                                f(row.bodyString)
                            )
                        )
                    ),

                    // selection restored,
                    editor.moveSelectionToRange(
                        startLocn,
                        editor.getLocationForItemOffset(
                            endItem,
                            endItem.bodyString.length
                        )
                    ),

                    // and message returned.
                    [
                        `${change} '${prefixChar}' prefix`,
                        `in ${items.length} selected lines.`
                    ]
                    .join("\n")
                );
            };


        // ------------------ PREFIXES -------------------

        // prefixed :: Char -> String -> String
        const prefixed = c =>
            s => `${c} ${dePrefixed(c)(s)}`;


        // dePrefixed :: String -> String -> String
        const dePrefixed = c =>
            s => s.startsWith(c) ? (
                s.slice(" " === s[1] ? 2 : 1)
            ) : s;

        // ---
        return tp3Main();
    };

    // ------------- JXA EVALUATION CONTEXT --------------

    // main :: IO ()
    const main = () => {
        const
            doc = Application("TaskPaper")
            .documents.at(0);

        return doc.exists() ? (
            doc.evaluate({
                script: `${tp3Context}`,
                withOptions: {prefix}
            })
        ) : "No document open in TaskPaper.";
    };

    // MAIN ---
    return main();
})();
1 Like

@complexpoint — You are amazing. I wasn’t going to take a look at this until later today, and you finished it be I could start.

Kudos!

1 Like

Rob—please forgive the eternal newbie’s question:

Instead of selecting the line after the prefix is toggled, I want to leave the text cursor at the same location that it was in before the prefix is added.

I have most of this figured out, except that my text cursor is two characters off (which makes sense, as we are adding two characters to the beginning of the line).

How do I adjust the location of the text cursor by two characters?

My current draft:

(() => {
    "use strict";

    // Toggling a prefix in selected TaskPaper lines.

    // Rob Trew @2022
    // Ver 0.05 (Jim's 0.06 variation)

    // (0.05 ignores empty lines in the selection)

    // --------------------- OPTIONS ---------------------

    const prefix = "—";

    // ---------- TASKPAPER EVALUATION CONTEXT -----------

    // eslint-disable-next-line max-lines-per-function
    const tp3Context = (editor, options) => {
        const
            selection = editor.selection,
            prefixChar = options.prefix.trim();

        // tp3Main :: () -> IO String
        const tp3Main = () => {
            const
                items = selection.selectedItems.filter(
                    x => 0 < x.bodyString.length
                );

            return 0 < items.length ? (
                toggledPrefixes(
                    ...(
                        items[0].bodyString.startsWith(
                            prefixChar
                        )
                    ) ? [
                            dePrefixed(prefixChar),
                            "CLEARED"
                        ] : [
                            prefixed(prefixChar),
                            "ADDED"
                        ]
                )(items)
            ) : "No items selected in TaskPaper";
        };

        // toggledPrefixes :: (String -> String) ->
        // String -> [TaskPaper Item] -> IO String
        const toggledPrefixes = (f, change) =>
            items => {

                return (
                    // Prefixes toggled,
                    editor.outline.groupUndoAndChanges(
                        () => items.forEach(
                            row => row.bodyString = (
                                f(row.bodyString)
                            )
                        )
                    ),

                    // selection restored,
            editor.moveSelectionToItems(selection)
                    ),

                    // and message returned.
                    [
                        `${change} '${prefixChar}' prefix`,
                        `in ${items.length} selected lines.`
                    ]
                    .join("\n")
            };


        // ------------------ PREFIXES -------------------

        // prefixed :: Char -> String -> String
        const prefixed = c =>
            s => `${c} ${dePrefixed(c)(s)}`;


        // dePrefixed :: String -> String -> String
        const dePrefixed = c =>
            s => s.startsWith(c) ? (
                s.slice(" " === s[1] ? 2 : 1)
            ) : s;

        // ---
        return tp3Main();
    };

    // ------------- JXA EVALUATION CONTEXT --------------

    // main :: IO ()
    const main = () => {
        const
            doc = Application("TaskPaper")
            .documents.at(0);

        return doc.exists() ? (
            doc.evaluate({
                script: `${tp3Context}`,
                withOptions: {prefix}
            })
        ) : "No document open in TaskPaper.";
    };

    // MAIN ---
    return main();
})();

You’re switching to .moveSelectionToItems ?

You don’t want to adjust (by 2) one of the integer arguments (start or finish) of .moveSelectionToRange ?

OutlineEditor interface

1 Like

I went with code that I (sort of) know, as I wasn’t able to figure out .moveSelectionToRange

Will try to figure out .moveSelectionToRange.

Got it. I think your approach is better – saves counting cumulative length changes.

Perhaps this kind of pattern ?

Expand disclosure triangle to view JS source
(() => {
    "use strict";

    // Toggling a prefix in selected TaskPaper lines.

    // Rob Trew @2022
    // Ver 0.06

    // (0.06 selection offsets preserved – not normalized)
    // (0.05 ignores empty lines in the selection)

    // --------------------- OPTIONS ---------------------

    const prefix = ">";

    // ---------- TASKPAPER EVALUATION CONTEXT -----------

    // eslint-disable-next-line max-lines-per-function
    const tp3Context = (editor, options) => {
        const
            selection = editor.selection,
            prefixChar = options.prefix.trim();

        // tp3Main :: () -> IO String
        const tp3Main = () => {
            const
                items = selection.selectedItems.filter(
                    x => 0 < x.bodyString.length
                );

            return 0 < items.length ? (
                toggledPrefixes(
                    ...(
                        items[0].bodyString.startsWith(
                            prefixChar
                        )
                    ) ? [
                            dePrefixed(prefixChar),
                            "CLEARED",
                            -2
                        ] : [
                            prefixed(prefixChar),
                            "ADDED",
                            2
                        ]
                )(items)
            ) : "No items selected in TaskPaper";
        };

        // toggledPrefixes :: (String -> String) ->
        // String -> [TaskPaper Item] -> IO String
        const toggledPrefixes = (f, change, lenDelta) =>
            items => {
                const
                    // Anchors for restored selection
                    // after updates.
                    [
                        startItem, startOffset,
                        endItem, endOffset
                    ] = ["start", "end"].flatMap(
                        pfx => ["Item", "Offset"].flatMap(
                            sfx => selection[`${pfx}${sfx}`]
                        )
                    );

                return (
                    // Prefixes toggled,
                    editor.outline.groupUndoAndChanges(
                        () => items.forEach(
                            row => row.bodyString = (
                                f(row.bodyString)
                            )
                        )
                    ),

                    // selection restored,
                    editor.moveSelectionToItems(
                        startItem, startOffset + lenDelta,
                        endItem, endOffset + lenDelta
                    ),

                    // and message returned.
                    [
                        `${change} '${prefixChar}' prefix`,
                        `in ${items.length} selected lines.`
                    ]
                    .join("\n")
                );
            };


        // ------------------ PREFIXES -------------------

        // prefixed :: Char -> String -> String
        const prefixed = c =>
            s => `${c} ${dePrefixed(c)(s)}`;


        // dePrefixed :: String -> String -> String
        const dePrefixed = c =>
            s => s.startsWith(c) ? (
                s.slice(" " === s[1] ? 2 : 1)
            ) : s;

        // ---
        return tp3Main();
    };

    // ------------- JXA EVALUATION CONTEXT --------------

    // main :: IO ()
    const main = () => {
        const
            doc = Application("TaskPaper")
            .documents.at(0);

        return doc.exists() ? (
            doc.evaluate({
                script: `${tp3Context}`,
                withOptions: {prefix}
            })
        ) : "No document open in TaskPaper.";
    };

    // MAIN ---
    return main();
})();
1 Like

You impress the [bleep] out of me Rob.

Thanks again!

Not me – kudos is to the patient architect and engineer of all this power and flexibilty.

1 Like

Agreed—Jesse is a genius!

1 Like