BIKE – Add linked footnote for current cursor position

A macro for Jesse Grosjean's Bike Outliner.


Creates a new footnote in Bike for the current cursor position:

  1. Inserts a link to a new note
  2. Creates the new note at the end of a named
    footnotes section. (Automatically created if not found)
  3. Selects the new note, giving focus to the footnotes section.

(To jump back and forth between the footnote and the row which contains a link to it, see a companion macro in Bike) which provides bidirectional linking in Bike)

BIKE – Add linked footnote for current cursor position.kmmacros (34 KB)


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

Other Keyboard Maestro macros for BIKE Outliner

1 Like