BIKE Outliner – Bidirectional linking (Jumping back and forth between link and target)

A macro for Jesse Grosjean's Bike Outliner.


For links within a document, for example to things like footnotes, Bike provides the menu option:

Format > Add Link to Row ... (⌥⌘K)


See also a companion macro for creating new footnotes linked to the current cursor position in Bike:

BIKE – Add linked footnote for current cursor position


After following a link within a document, how do we jump back to the row which contained that link ?

Here is a rough draft of a macro, bound by default to ⌘J, which jumps back and forth between the link target and the row containing the link:

BIKE – Bidirectional linking – Jumping back and forth between link and target.kmmacros (43 KB)


DETAILS

  • The macro uses Bike's Outline Paths to find rows with links pointing to the selected row.
  • With repeated taps of ⌘J, focus and selection jump back and forth between the targeted row and the row containing the link.
  • Where the selected row contains several links, repeated use of ⌘J cycles back and forth, visiting each link target in succession.
  • Where the selected row is a target of more than one link, a menu offers a choice of which row to jump back to.

Expand disclosure triangle to view JS source
return (() => {
    "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.

    // Where more than one row has links to the same target,
    // a menu is offered for the user to choose from.

    // Tested only with Bike 1.18.2 (Preview 176)

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

    // Rob Trew @2024
    // Ver 0.03

    ObjC.import("AppKit");

    const bike = Application("Bike");

    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 = "bikeLinksQueueState";

    // 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 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 => {
                const nLinks = linkSourceRows.length;

                return 1 < nLinks
                    ? bindLR(
                        chosenRowLR(
                            "Choice of incoming links"
                        )(
                            "Select:"
                        )(linkSourceRows)
                    )(
                        compose(Right, jumpedToRow(doc))
                    )
                    : Right(
                        jumpedToRow(doc)(linkSourceRows[0])
                    );
            }
        )(
            rowsLinkingToTargetRowLR(doc)(row)
        );

    const chosenRowLR = withTitle =>
        withPrompt => rows => {
            const
                sa = Object.assign(
                    // Application.currentApplication(),
                    Application("System Events"),
                    {includeStandardAdditions: true}
                ),
                menu = rows.map((row, i) =>
                    `${1 + i}\t${row.name()}`
                ),
                choice = (
                    sa.activate(),
                    sa.chooseFromList(
                        menu,
                        {
                            withTitle,
                            withPrompt,
                            defaultItems: [menu[0]],
                            multipleSelectionsAllowed: false
                        }
                    )
                );

            return choice
                ? Right(
                    rows[Number(choice[0].split("\t")[0]) - 1]
                )
                : Left("User cancelled");
        };

    // 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 => (
            bike.activate(),
            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();
})();

Other Keyboard Maestro macros for BIKE Outliner

2 Likes