Footnotes ( bi-directional linking ) in Bike?

Not sure whether others are using anything corresponding to footnotes in their Bike documents, but here is a draft script, for testing, which aims to make it easier to work with internal links, as in Format > Add Link to Row (⌥⌘K)

Its easy enough to click a link which takes us to another row (perhaps, conceptually, analogous to a traditional footnote),

but how do we jump back from the footnote row, to the row which contains the link ?

@jessegrosjean pointed out to me that the trick (in a script) is to let an Outline Path find rows which link to the selected row.

Expand disclosure triangle to view an example of JavaScript path use
const
    targetID = targetRow.id(),
    matches = doc.query({
        outlinePath: (
            `//*/run::@link endswith "#${targetID}"/..`
        )
    });

Here, then, is a draft script which aims to:

  1. Jump back from the selected line to a row which contains a link to it, or
  2. if the selected line itself contains an internal link, then jump to the target of its link, or
  3. if it contains multiple links to other rows, then chose the next link (cycling through them, remembering where it jumped last time), or
  4. Just show a notification if the selected row is neither linked to, nor a container of links.

The idea, in short, is that if you bind a key like ⌘J to this script, you should just be able to tap it repeatedly to move back and forth between footnotes and the rows which they elaborate or reference evidence / authority for.


For memory of which link (in a multiply linked line) was followed last, the script aims to:

  1. Use a Keyboard Maestro variable, if Keyboard Maestro is installed on your system, and otherwise
  2. use a .plist file in the same folder as the script.

Source – note that KM11 requires a return prefix at the start of a script, as in return (() => {

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

    // Jump from and to internal links in the front
    // https://www.hogbaysoftware.com/bike/
    // document.

    // Where a line contains links to more than one
    // other point in the document, the script
    // cycles back and forth between the various links.

    // Tested only with Bike 1.18.2 (Preview 176)

    // -------- JAVASCRIPT FOR AUTOMATION SCRIPT ---------

    // Rob Trew @2024
    // Ver 0.01

    ObjC.import("AppKit");

    const
        // Used if Keyboard Maestro is installed
        // on the system.
        // If not, when memory of state is required,
        // a .plist file in the same folder as the script
        // is used.
        keyboardMaestroEngineID = (
            "com.stairways.keyboardmaestro.engine"
        ),
        // Used either for a Keyboard Maestro variable name
        // bound to a JSON value, or for the name of a
        // plist dictionary file.
        stateName = "rowLinksQueueState";

    // External state is used only if a selected row
    // happens to contain more than one internal link.
    // It allows us to toggle-cycle through the links
    // with repeated calls to this script.


    // ---------------------- MAIN -----------------------
    const main = () => {
        const
            bike = Application("Bike"),
            frontDoc = bike.documents.at(0);

        return ((
            bike.activate(),
            either(
                notify(
                    "Jump to and from internal links"
                )("")("Pop")
            )(
                report => report
            )(
                bindLR(
                    frontDoc.exists()
                        ? Right(frontDoc.selectionRow())
                        : Left("No document open in Bike.")
                )(
                    jumpToOrFromInternalLinkLR(stateName)(
                        frontDoc
                    )
                )
            )
        ));
    };


    // jumpToOrFromInternalLinkLR :: Bike Document ->
    // Bike Row -> Either String String
    const jumpToOrFromInternalLinkLR = memoryName =>
        doc => row => either(
            () => bindLR(
                internalLinksInGivenRowLR(doc)(row)
            )(
                // 'Out' to the next internal
                // link target in this row
                nextLinkFollowedLR(memoryName)(doc)(row)
            )
        )(
            // 'Back to' the first row with
            // a link to the selected line.
            linkSourceRows => Right(
                jumpedToRow(doc)(
                    linkSourceRows[0]
                )
            )
        )(
            rowsLinkingToTargetRowLR(doc)(row)
        );


    // nextLinkFollowed :: String -> Bike Document ->
    // Bike Row -> [URL String] -> IO Either String String
    const nextLinkFollowedLR = memoryName =>
        // 'Out from' next link found
        // in this row.
        doc => row => rowLinks =>
            linkFollowedWithinDocLR(doc)(
                rowLinks[(
                    nextLinkIndex(memoryName)(
                        doc
                    )(rowLinks.length)(row)
                )]
            );


    // nextLinkIndex :: String -> Bike Doc ->
    // Int -> Bike Row -> Int
    const nextLinkIndex = memoryName =>
        doc => nLinks => bikeRow => {
            const
                memoryDict = stateRetrieved(memoryName),
                rowID = bikeRow.id(),
                sameDoc = doc.id() in memoryDict,
                // First link, or next where there is
                // memory of a preceding link choice.
                nextIndex = 1 < nLinks
                    ? sameDoc && (
                        rowID in memoryDict
                    )
                        ? (1 + memoryDict[rowID]) % nLinks
                        : 0
                    : 0;

            return (
                stateStored(sameDoc)(memoryName)({
                    [doc.id()]: true,
                    [rowID]: nextIndex
                }),
                nextIndex
            );
        };

    // ---------------------- BIKE -----------------------

    // internalLinksInGivenRowLR :: Bike Doc ->
    // Bike Row -> Either String [Bike URL]
    const internalLinksInGivenRowLR = doc =>
        targetRow => {
            const
                docID = doc.id(),
                linksToRows = linksInRow(doc)(targetRow)
                .filter(
                    link => link.includes(docID)
                );

            return 0 < linksToRows.length
                ? Right(linksToRows)
                : Left(
                    "No links to other rows in selected line."
                );
        };


    // bikeURLFocusAndRowIDsLR :: Bike URL ->
    // Either String (String, String)
    const bikeURLFocusAndRowIDsLR = bikeURL => {
        const
            idPair = bikeURL
            .split(/\//u)
            .slice(-1)[0]
            .split(/#/u);

        return 2 === idPair.length
            ? Right(idPair)
            : Left(`Unexpected URL pattern: "${bikeURL}"`);
    };


    // linkFollowedWithinDocLR :: Bike Doc ->
    // Bike Row -> IO Either String String
    const linkFollowedWithinDocLR = doc =>
        bikeURL => bindLR(
            bikeURLFocusAndRowIDsLR(bikeURL)
        )(
            ([focusID, rowID]) => {
                const
                    rows = doc.rows,
                    focusRow = rows.where({id: focusID}).at(0),
                    targetRow = rows.where({id: rowID}).at(0);

                return (
                    // Effect
                    focusRow.exists() && (
                        doc.focusedRow = focusRow
                    ),
                    targetRow.exists()
                        ? (
                            // Effect
                            doc.select({at: targetRow}),

                            // Value
                            Right(`(${rowID}) '${targetRow.name()}'`)
                        )
                        : Left(`Row not found by id: '${rowID}'`)
                );
            }
        );


    // linksInRow :: Bike Doc ->
    // Bike Row -> [Bike URL]
    const linksInRow = doc =>
        row => linksInHTML(
            doc.export({
                from: row,
                as: "bike format",
                all: false
            })
        );


    // jumpedToRow :: Bike Document -> Bike Row -> IO String
    const jumpedToRow = doc =>
        row => (
            doc.focusedRow = row.containerRow(),
            doc.select({at: row}),
            `Selected: (${row.id()}) "${row.name()}"`
        );


    // rowsLinkingToTargetRowLR :: Bike Document ->
    // Bike Row -> IO Either String Bike Rows
    const rowsLinkingToTargetRowLR = doc =>
        targetRow => {
            const
                targetID = targetRow.id(),
                matches = doc.query({
                    outlinePath: (
                        `//*/run::@link endswith "#${targetID}"/..`
                    )
                });

            return 0 < matches.length
                ? Right(matches)
                : Left(
                    [
                        "No links point to row:",
                        `(${targetID}) "${targetRow.name()}"`
                    ]
                    .join("\n")
                );
        };

    // ---------------- PERSISTENT STATE -----------------

    // stateRetrieved :: String -> IO Dict
    const stateRetrieved = storeName =>
        // Either an empty dictionary, or, if found,
        // a dictionary retrieved from JSON in a named
        // Keyboard Maestro variable (if KM is installed)
        // or otherwise from a ${storeName}.plist file in the
        // same folder as this script.
        (
            appIsInstalled(
                keyboardMaestroEngineID
            )
                ? dictFromKMVar
                : dictFromPlist
        )(storeName);


    // stateStored :: Boolean -> String ->
    // Dict -> IO Dict
    const stateStored = mergedWithExisting =>
        // The entries of the given dictionary either
        // (merged with/added to) a JSON string in a named
        // Keyboard Maestro variable (if KM is installed)
        // or (merged with/added to) a ${storeName}.plist
        // file in the same folder as this script.
        (
            appIsInstalled(
                keyboardMaestroEngineID
            )
                ? dictToKMVar
                : dictToPlist
        )(mergedWithExisting);


    // dictFromKMVar :: String -> Dict
    const dictFromKMVar = storeName =>
        either(() => ({}))(
            dict => dict
        )(
            jsonParseLR(
                Application("Keyboard Maestro Engine")
                .getvariable(storeName)
            )
        );


    // dictFromPlist :: String -> Dict
    const dictFromPlist = storeName =>
        either(() => ({}))(
            dict => dict
        )(
            readPlistFileLR(
                combine(
                    Object.assign(
                        Application.currentApplication(),
                        {includeStandardAdditions: true}
                    )
                    .doShellScript("pwd")
                )(`${storeName}.plist`)
            )
        );

    // dictToKMVar :: Boolean -> String ->
    // Dict -> IO String
    const dictToKMVar = mergedWithExisting =>
        storeName => dict => {
            const kme = Application("Keyboard Maestro Engine");

            return (
                kme.setvariable(
                    storeName,
                    {
                        to: JSON.stringify(
                            either(() => ({}))(
                                existingDict => Object
                                .assign(
                                    existingDict, dict
                                )
                            )(
                                jsonParseLR(
                                    mergedWithExisting
                                        ? kme.getvariable(
                                            storeName
                                        )
                                        : "{}"
                                )
                            ),
                            null, 2
                        )
                    }
                ),
                kme.getvariable(storeName)
            );
        };


    // dictToPlist :: Boolean -> String -> String ->
    // Dict -> Either String Dict
    const dictToPlist = mergedWithExisting =>
        // Either a string or key-value pair written to
        // a .plist dictionary in the same directory as
        // the current script.
        fileBaseName => dictKVs => {
            const
                fpState = combine(
                    Object.assign(
                        Application.currentApplication(),
                        {includeStandardAdditions: true}
                    )
                    .doShellScript("pwd")
                )(
                    `${fileBaseName}.plist`
                );

            return (
            // ----------------- EFFECT ------------------
                writePlist(
                    mergedWithExisting
                        ? Object.assign(
                            readPlistFileLR(fpState)
                            .Right || {},
                            dictKVs
                        )
                        : dictKVs
                )(fpState),
                // ---------------- VALUE ----------------
                readPlistFileLR(fpState)
            );
        };

    // ----------------------- JXA -----------------------

    // appIsInstalled :: String -> Bool
    const appIsInstalled = bundleID =>
        Boolean(
            $.NSWorkspace.sharedWorkspace
            .URLForApplicationWithBundleIdentifier(
                bundleID
            )
            .fileSystemRepresentation
        );


    // doesFileExist :: FilePath -> IO Bool
    const doesFileExist = fp => {
        const ref = Ref();

        return $.NSFileManager
        .defaultManager
        .fileExistsAtPathIsDirectory(
            $(fp).stringByStandardizingPath,
            ref
        ) && !ref[0];
    };


    // linksInHTML :: HTML String -> [URL]
    const linksInHTML = html =>
        // A (possibly empty) list of URLs.
        bindLR(
            parseFromHTML(html)
        )(
            compose(
                map(x => x.attributes.href),
                filterTree(x => "a" === x.name)
            )
        );


    // notify :: String -> String -> String ->
    // String -> IO ()
    const notify = withTitle =>
        subtitle => soundName => message =>
            Object.assign(
                Application.currentApplication(),
                {includeStandardAdditions: true}
            )
            .displayNotification(
                message,
                {
                    withTitle,
                    subtitle,
                    soundName
                }
            );


    // parseFromHTML :: String -> Either String Tree Dict
    const parseFromHTML = html => {
        const
            error = $(),
            node = $.NSXMLDocument.alloc
            .initWithXMLStringOptionsError(
                html, 0, error
            );

        return node.isNil()
            ? Left(`Not parseable as XML: ${html}`)
            : Right(xmlNodeDict(node));
    };


    // readPlistFileLR :: FilePath -> Either String Dict
    const readPlistFileLR = fp =>
        // Either a message or a dictionary of key-value
        // pairs read from the given file path.
        bindLR(
            doesFileExist(fp)
                ? Right(filePath(fp))
                : Left(`No file found at path:\n\t${fp}`)
        )(
            fpFull => {
                const
                    e = $(),
                    maybeDict = $.NSDictionary
                    .dictionaryWithContentsOfURLError(
                        $.NSURL.fileURLWithPath(fpFull),
                        e
                    );

                return maybeDict.isNil()
                    ? (() => {
                        const
                            msg = ObjC.unwrap(
                                e.localizedDescription
                            );

                        return Left(`readPlistFileLR:\n\t${msg}`);
                    })()
                    : Right(ObjC.deepUnwrap(maybeDict));
            }
        );


    // writePlist :: Dict -> FilePath -> Either String IO ()
    const writePlist = dict =>
        // A dictionary of key value pairs
        // written to the given file path.
        fp => $(dict)
        .writeToFileAtomically(
            $(fp).stringByStandardizingPath, true
        );


    // xmlNodeDict :: NSXMLNode -> Tree Dict
    const xmlNodeDict = xmlNode => {
    // A Tree of dictionaries derived from an NSXMLNode
    // in which the keys are:
    // name (tag), content (text), attributes (array)
    // and XML (source).
        const
            uw = ObjC.unwrap,
            hasChildren = 0 < parseInt(
                xmlNode.childCount, 10
            );

        return Node({
            name: uw(xmlNode.name),
            content: hasChildren
                ? undefined
                : (uw(xmlNode.stringValue) || " "),
            attributes: (() => {
                const attrs = uw(xmlNode.attributes);

                return Array.isArray(attrs)
                    ? attrs.reduce(
                        (a, x) => Object.assign(a, {
                            [uw(x.name)]: uw(
                                x.stringValue
                            )
                        }),
                        {}
                    )
                    : {};
            })(),
            xml: uw(xmlNode.XMLString)
        })(
            hasChildren
                ? uw(xmlNode.children).map(xmlNodeDict)
                : []
        );
    };


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

    // Left :: a -> Either a b
    const Left = x => ({
        type: "Either",
        Left: x
    });


    // Node :: a -> [Tree a] -> Tree a
    const Node = v =>
        // Constructor for a Tree node which connects a
        // value of some kind to a list of zero or
        // more child trees.
        xs => ({
            type: "Node",
            root: v,
            nest: xs || []
        });


    // Right :: b -> Either a b
    const Right = x => ({
        type: "Either",
        Right: x
    });


    // bindLR (>>=) :: Either a ->
    // (a -> Either b) -> Either b
    const bindLR = lr =>
        // Bind operator for the Either option type.
        // If lr has a Left value then lr unchanged,
        // otherwise the function mf applied to the
        // Right value in lr.
        mf => "Left" in lr
            ? lr
            : mf(lr.Right);


    // combine (</>) :: FilePath -> FilePath -> FilePath
    const combine = fp =>
        // The concatenation of two filePath segments,
        // without omission or duplication of "/".
        fp1 => Boolean(fp) && Boolean(fp1)
            ? "/" === fp1.slice(0, 1)
                ? fp1
                : "/" === fp.slice(-1)
                    ? fp + fp1
                    : `${fp}/${fp1}`
            : (fp + fp1);


    // compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
    const compose = (...fs) =>
    // A function defined by the right-to-left
    // composition of all the functions in fs.
        fs.reduce(
            (f, g) => x => f(g(x)),
            x => x
        );


    // either :: (a -> c) -> (b -> c) -> Either a b -> c
    const either = fl =>
        // Application of the function fl to the
        // contents of any Left value in e, or
        // the application of fr to its Right value.
        fr => e => "Left" in e
            ? fl(e.Left)
            : fr(e.Right);


    // filePath :: String -> FilePath
    const filePath = s =>
    // The given file path with any tilde expanded
    // to the full user directory path.
        ObjC.unwrap(
            $(s).stringByStandardizingPath
        );


    // filterTree (a -> Bool) -> Tree a -> [a]
    const filterTree = p =>
    // List of all values in the tree
    // which match the predicate p.
        foldTree(x => xs =>
            p(x)
                ? [x, ...xs.flat()]
                : xs.flat()
        );


    // foldTree :: (a -> [b] -> b) -> Tree a -> b
    const foldTree = f => {
    // The catamorphism on trees. A summary
    // value obtained by a depth-first fold.
        const go = tree => f(
            tree.root
        )(
            tree.nest.map(go)
        );

        return go;
    };


    // jsonParseLR :: String -> Either String a
    const jsonParseLR = s => {
        try {
            return Right(JSON.parse(s));
        } catch (e) {
            return Left(
                [
                    e.message,
                    `(line:${e.line} col:${e.column})`
                ].join("\n")
            );
        }
    };


    // map :: (a -> b) -> [a] -> [b]
    const map = f =>
    // The list obtained by applying f
    // to each element of xs.
    // (The image of xs under f).
        xs => [...xs].map(f);


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

Keyboard Maestro macro, binding the script to ⌘J

BIKE - JUMP back and forth between internal link and the targeted row.kmmacros.zip (18.6 KB)


“Aims to” is the key here – no guarantees at all. Please remember to back data up and use a dummy document for testing.

6 Likes

Ver 0.3 ( offers a menu when the selected row is a target of more than one link ) can be found at:


To use the source listed there outside Keyboard Maestro (for example to test in Script Editor, or bind to a keystroke with Fastscripts) remove the leading return
i.e.

edit from the opening:

return (() => {
    "use strict";

to just:

(() => {
    "use strict";

If testing in Script Editor, change the language selector at top left from AppleScript to JavaScript.

See: Using Scripts | Bike

1 Like

See also a companion macro:


To test outside Keyboard Maestro, in Script Editor, set the language selector at top left to JavaScript rather than AppleScript, and use the following source:

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

    ObjC.import("AppKit");

    // Create a new footnote:
    // 1. Insert a link to a new note at the current cursor
    //    position.
    // 2. Create the new note at the end of a named
    //    footnotes section. (Automatically created if not found)
    // 3. Select the new note, giving focus to the footnote section.

    // A companion script for:
    // [Bidirectional linking](
    //  https://forum.keyboardmaestro.com/t/bike-outliner-bidirectional-linking-jumping-back-and-forth-between-link-and-target/35804
    // )
    // which jumps back and forth between such footnotes and the
    // links which point to them.

    // Rob Trew @2024
    // Ver 0.01

    // ---------------------- MAIN -----------------------
    const main = () => {
        const title = "Insert link to new footnote.";
        const footnoteSectionName = "Footnotes";
        const inlineFootnoteMark = "*";

        const
            bike = Application("Bike"),
            frontDoc = bike.documents.at(0);

        return either(
            alert(title)
        )(
            notify(title)("")("pop")
        )(
            bindLR(
                footnoteMarkCheckedLR(inlineFootnoteMark)
            )(
                compose(
                    bindLR(
                        frontDoc.exists()
                            ? Right(frontDoc)
                            : Left("No document open in Bike.")
                    ),
                    newNoteAndLinkLR(footnoteSectionName)(
                        bike
                    )
                )
            )
        );
    };


    // footnoteMarkCheckedLR :: String -> Either String String
    const footnoteMarkCheckedLR = mark =>
        0 < mark.length
            ? Right(mark)
            : Left(
                [
                    "Inline footnote mark can be a space, ",
                    "or a visible character like ^,",
                    "but not an empty string."
                ]
                .join("\n")
            );


    // newNoteAndLinkLR :: String -> Bike Application ->
    // Bike Document -> Either IO String IO String
    const newNoteAndLinkLR = footnoteSectionName =>
        bike => inlineFootnoteMark => doc => {
            const
                firstSelectedRow = doc.rows
                .where({selected: true})
                .at(0);

            return bindLR(
                firstSelectedRow.exists()
                    ? Right(firstSelectedRow)
                    : Left(
                        `Nothing selected in ${doc.name()}`
                    )
            )(
                () => newFootnoteAddedLR(bike)(doc)(
                    inlineFootnoteMark
                )(footnoteSectionName)
            );
        };


    // newFootnoteAddedLR :: Bike Application ->
    // Bike Document -> String ->
    // String -> IO Either String String
    const newFootnoteAddedLR = app =>
        doc => footnoteLinkMark => footNotesSectionName => {
            const
                footNoteParent = namedRowFoundOrCreated(app)(
                    footNotesSectionName
                )(doc);

            return bindLR(
                newNoteChildLR(doc)(footNoteParent)("")
            )(
                footNoteRow => (
                    // Effects,
                    copyTypedString(true)(
                        "com.hogbaysoftware.bike.xml"
                    )(
                        linkXML(
                            doc.id()
                        )(
                            footNoteParent.id()
                        )(
                            footnoteLinkMark
                        )(
                            footNoteRow.id()
                        )
                    ),
                    menuItemClicked("Bike")([
                        "Edit", "Paste", "Paste"
                    ]),
                    doc.select({at: footNoteRow}),
                    doc.focusedRow = footNoteParent,

                    // Value.
                    Right("New footnote selected.")
                )
            );
        };


    // linkXML :: String -> String -> String ->
    // String -> XML String
    const linkXML = docID =>
        parentID => mark => footNoteID => {
            const url = `bike://${docID}/${parentID}#${footNoteID}`;

            return (
                `<?xml version="1.0" encoding="UTF-8"?>
                <html xmlns="http://www.w3.org/1999/xhtml">
                <head><meta charset="utf-8"/></head>
                <body><ul><li>
                    <p><a href="${url}">${mark}</a></p>
                </li></ul></body>
                </html>`
            );
        };


    // namedRowFoundOrCreated :: Bike Application ->
    // String -> Bike Document -> Bike Row
    const namedRowFoundOrCreated = app =>
        name => doc => {
            const
                firstExisting = doc.rows.where({name})
                .at(0);

            return firstExisting.exists()
                ? firstExisting
                : (() => {
                    const newRow = new app.Row({name});

                    return (
                        doc.rows.push(newRow),
                        newRow
                    );
                })();
        };


    // newNoteChildLR :: Bike Doc -> Bike Row ->
    // String -> IO Either String Bike Row
    const newNoteChildLR = doc =>
        parentRow => name => parentRow.exists()
            ? (() => {
                const
                    addedRows = doc.import({
                        from: footNoteXML(name),
                        to: parentRow,
                        as: "bike format"
                    });

                return 0 < addedRows.length
                    ? Right(addedRows[0])
                    : Left(`New row not added: '${name}'`);
            })()
            : Left("Parent row not found in noteChildAddedLR");


    // footNoteXML :: String -> XML String
    const footNoteXML = name =>
        `<html xmlns="http://www.w3.org/1999/xhtml">
        <head><meta charset="utf-8"/></head>
        <body>
            <ul><li data-type="note">
            <p>${name}</p>
            </li></ul>
        </body>
        </html>`;


    // ----------------------- JXA -----------------------

    // alert :: String => String -> IO String
    const alert = title =>
        s => {
            const sa = Object.assign(
                Application("System Events"), {
                    includeStandardAdditions: true
                });

            return (
                sa.activate(),
                sa.displayDialog(s, {
                    withTitle: title,
                    buttons: ["OK"],
                    defaultButton: "OK"
                }),
                s
            );
        };


    // copyTypedString :: Bool -> String -> String -> IO ()
    const copyTypedString = blnClear =>
    // public.html, public.rtf, public.utf8-plain-text
        pbUTI => s => {
            const pb = $.NSPasteboard.generalPasteboard;

            return (
                blnClear && pb.clearContents,
                pb.setStringForType(
                    $(s),
                    $(pbUTI)
                ),
                s
            );
        };


    // menuItemClicked :: String -> [String] -> IO Bool
    const menuItemClicked = appName =>
    // Click an OS X app sub-menu item
    // 2nd argument is an array of arbitrary length
    // (exact menu item labels, giving full path)
        menuItems => {
            const nMenuItems = menuItems.length;

            return nMenuItems > 1
                ? (() => {
                    const
                        appProcs = Application("System Events")
                        .processes.where({
                            name: appName
                        });

                    return 0 < appProcs.length
                        ? (
                            Application(appName)
                            .activate(),
                            menuItems.slice(1, -1)
                            .reduce(
                                (a, x) => a.menuItems[x]
                                .menus[x],
                                appProcs[0].menuBars[0]
                                .menus.byName(menuItems[0])
                            )
                            .menuItems[menuItems[nMenuItems - 1]]
                            .click(),
                            true
                        )
                        : false;
                })()
                : false;
        };


    // notify :: String -> String -> String ->
    // String -> IO ()
    const notify = withTitle =>
        subtitle => soundName => message =>
            Object.assign(
                Application.currentApplication(),
                {includeStandardAdditions: true}
            )
            .displayNotification(
                message,
                {
                    withTitle,
                    subtitle,
                    soundName
                }
            );


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

    // Left :: a -> Either a b
    const Left = x => ({
        type: "Either",
        Left: x
    });


    // Right :: b -> Either a b
    const Right = x => ({
        type: "Either",
        Right: x
    });


    // bindLR (>>=) :: Either a ->
    // (a -> Either b) -> Either b
    const bindLR = lr =>
        // Bind operator for the Either option type.
        // If lr has a Left value then lr unchanged,
        // otherwise the function mf applied to the
        // Right value in lr.
        mf => "Left" in lr
            ? lr
            : mf(lr.Right);


    // compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
    const compose = (...fs) =>
    // A function defined by the right-to-left
    // composition of all the functions in fs.
        fs.reduce(
            (f, g) => x => f(g(x)),
            x => x
        );


    // either :: (a -> c) -> (b -> c) -> Either a b -> c
    const either = fl =>
        // Application of the function fl to the
        // contents of any Left value in e, or
        // the application of fr to its Right value.
        fr => e => "Left" in e
            ? fl(e.Left)
            : fr(e.Right);


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

See: Using Scripts | Bike

1 Like

Ha, I like the pop sound when I create my footnote :slight_smile:

1 Like

These scripts are all working for me. Nice stuff!

1 Like

Perhaps unrelated to this (but also related), is there an option to open a link using a keyboard shortcut? A popular binding in other apps is cmd+Return when the cursor is inside the link text.

1 Like

2 posts were split to a new topic: Bike – Open Link ⌘↩?

Ooh, this is cool, @complexpoint!
I’m trying to make a Shortcuts based version!

But I’m struggling with one thing: What’s the best way to indent a newly created row?

I don’t see any mention of indenting while Creating Editing or Moving rows, for instance.

And I can’t use AppleScript to send a tab, as the line doesn’t have focus. I could maybe mix-and-match with Bike related AppleScript - but I’m feeling there should be a way to simply indent a row with all those shortcut actions! :upside_down_face:

You can define the destination row when using the Move/Create rows action. Start of/End of parameter will put the moved/created row inside the destination row (thus creating a child row), Before/After will move it next to it (sibling).

2 Likes

Aha, OK - thanks!

(I don’t find that wording intuitive at all :sweat_smile: - but I see it now. Also, they’re at a place without hover text, so I never found a place they were they were explained.)

OK, so here’s a draft for a shortcut action that does something similar!

I was very happy with myself when I figured out how to automatically number the footnotes - but then I rememberd that people don’t necessarily make them in order. :sweat_smile: But now it will number the first footnote you make 1, the second 2, etc. It’s just text for the hyperlink, so if you change the order, it’s easy to switch it around.


Does anyone know if there’s a way to get a Bike link that scrolls down to a row without focusing in on it?

I have the links going to the parents now, as to not lose as much sense of direction. Do I have to use paths?

In action: :point_down:t2:

Here’s the full Bike linking syntax. Try removing the ‘focusid’ altogether, or use ‘rootid’ also as ‘focusid’. I haven’t tried it, so I’m not sure how that would work.

Yeah, I tried removing the focusid - but then it just goes to the root.

Haven’t tried duplicating it to the end, though.