BIKE Outliner :: Save As Markdown

A Save As Markdown macro for Jesse Grosjean's Bike outliner

BIKE -- Save As Markdown.kmmacros (27 KB)


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

    // -------- DEMO OF SAVE AS MARKDOWN FOR BIKE --------

    ObjC.import("AppKit");

    // A basic `Save As Markdown` serialization of the
    // active [Bike 1.3+]( Build 61+ ) outline document.

    // Top N levels used as MD heading levels
    // (Except for any quote or code lines at those levels)

    // Next level (N + 1) used as paragraphs.
    // (Use a blank line in Bike to delimit paragraphs)

    // All levels below paragraphs are interpreted as
    // nested lists - bulleted by default, but adopting
    // any numeric list prefixes (numbers followed by
    // dot and space) that are found in the outline.


    // Rob Trew @2022
    // Draft ver 0.06

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

    const howManyHeadingLevels = 2;
    const startHeadingLevel = 2;

    // If you are using blank nodes to separate paragraphs
    // in Bike, set `nodesAreParagraphs` (below) to false.

    // (Bike nodes at the level below hash headings will be
    // run together as sentences, and your paragraph breaks
    // will be preserved in the Markdown.)

    // If `nodesAreParagraphs` is set to true, extra blank
    // lines will be inserted between Bike nodes in the
    // Markdown, at the level below hash headings.
    const nodesAreParagraphs = false;

    // Set `ignoreKeyboardMaestro` to true (below)
    // if you don't have Keyboard Maestro installed, or
    // prefer to use the option values above directly,
    // rather than than import values set in KM.
    // (See the use of `readSettings` in `main()` below)
    const ignoreKeyboardMaestro = false;


    // -- DEMO SERIALISATION OF BIKE TO BASIC MARKDOWN ---

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

        return either(
            alert("Save Bike outline as Markdown")
        )(
            md => md
        )(
            doc.exists() ? (
                bindLR(
                    (
                        bike.activate(),
                        filePathFromBikeDocLR(doc)
                    )
                )(
                    fpIn => bindLR(
                        outPathWithNewExtensionConfirmedLR(
                            "md"
                        )(fpIn)
                    )(
                        flip(writeFileLR)(
                            bikeRowsMD(
                                ignoreKeyboardMaestro
                            )(doc.rows)
                        )
                    )
                )
            ) : Left("No document open in Bike")
        );
    };


    // readSettings :: Bool -> IO (Int, Int)
    const readSettings = ignoreKM =>
        // Reading the global option settings
        // at the top of the script.
        ignoreKM ? ([
            howManyHeadingLevels,
            startHeadingLevel,
            nodesAreParagraphs
        ]) : kmOrManualSettings(
            [
                "HowManyHeadingLevels", howManyHeadingLevels
            ],
            [
                "TopHeadingLevel", startHeadingLevel
            ],
            [
                "NodesAreParagraphs", nodesAreParagraphs
            ]
        );


    // headingListFromSettings :: Int -> Int -> [String]
    const headingListFromSettings = nLevels =>
        // The list of hash heading levels required.
        nFirst => enumFromTo(nFirst)(
            nFirst + (nLevels - 1)
        )
        .map(n => `${"#".repeat(n)} `);


    // kmOrManualSettings ::
    // ((String, Int), (String, Int), (String, Bool))
    //  -> IO (Int, Int, Bool)
    const kmOrManualSettings = (kvLevels, kvTop, kvParas) =>
        appIsInstalled(
            "com.stairways.keyboardmaestro.engine"
        ) ? (() => {
            const
                kmVar = Application(
                    "Keyboard Maestro Engine"
                ).getvariable,
                vs = [kvLevels, kvTop, kvParas].map(
                    ([k, n]) => parseInt(kmVar(k), 10) || n
                );

            return [vs[0], vs[1], Boolean(vs[2])];
        })() : [kvLevels, kvTop, kvParas].map(ab => ab[1]);


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

    // bikeRowsForest :: Bike Rows ->
    // [Tree {text:String, level:Int}]
    const bikeRowsForest = rows =>
        // A forest of strings representing the outline(s)
        // in the rows of a Bike 1.3 document.
        forestFromIndentedLines(
            zip(
                rows.level()
            )(
                rows.name()
            )
        );


    // --------------- MARKDOWN FROM BIKE ----------------


    // -- TOP N OUTLINE LEVELS USED FOR HEADINGS
    // -- (Excepting code and quote nodes at those levels)
    // -- THEN PARAGRAPHS OF TEXT
    // -- AND BELOW THAT, NESTED BULLET LISTS


    // bikeRowsMD :: Bool -> Bike Rows -> IO String
    const bikeRowsMD = ignoreKM =>
        rows => {
            // Markdown String, optionally in terms of
            // settings stored in Keyboard Maestro.
            const
                forest = bikeRowsForest(rows),
                [
                    nHeadingLevels, topHeadingLevel,
                    nodesAreParas
                ] = readSettings(ignoreKM),
                hashHeadings = headingListFromSettings(
                    nHeadingLevels
                )(topHeadingLevel).filter(
                    x => x.length <= 6
                );

            return mdBlockedForest(forest).map(
                    mdFromTree(hashHeadings)(nodesAreParas)
                )
                .join("\n")
                // With any excessive gaps trimmed out.
                .replace(/\n{3,}/gu, "\n\n");
        };


    // mdFromTree :: [String] -> Bool ->
    // Tree {text::String, level::Int} -> String
    const mdFromTree = hashPrefixes =>
        // A basic Markdown serialization of a tree of
        // {text, level} dictionaries, in which hashPrefixes,
        // a list of length N, contains the MD prefixes for
        // the top N levels of the tree, which are to be
        // interpreted as headings.
        // No inline formatting (links etc) is attempted.
        nodesAreParas => tree => foldTree(
            node => mds => (
                Boolean(node.text.trim())
            ) ? (
                ["code", "quote"].includes(node.mdType) ? (
                    node.text
                ) : levelNodeMD(hashPrefixes)(
                    nodesAreParas
                )(node)(mds.join(""))
            ) : `\n${mds.join("")}\n`
        )(tree)
        // With any excessive gaps trimmed out.
        .replace(/\n{3,}/gu, "\n\n");


    // levelNodeMD :: [String] -> Bool ->
    // {text::String, level::Int} -> String -> String
    const levelNodeMD = hashPrefixes =>
        // Markdown representation of a given tree node
        // based on the level of its nesting,
        // and the given number and length of hashPrefixes.
        nodesAreParas => node => subMD => {
            const
                level = node.level,
                nHashLevels = hashPrefixes.length,
                paraLevel = nHashLevels + 1,
                pfx = level < paraLevel ? (
                    hashPrefixes[level - 1]
                ) : level === paraLevel ? (
                    ""
                ) : "    ".repeat(
                    (level - paraLevel) - 1
                ),
                txt = node.text.trim(),
                mdtxt = `${pfx}${txt}`;

            return level < paraLevel ? (
                level === nHashLevels ? (
                    lowestHeading(mdtxt)(subMD)
                ) : `${mdtxt}\n\n${subMD}`
            ) : level === paraLevel ? (
                paragraphLine(nodesAreParas)(txt)(subMD)
            ) : listItem(pfx)(txt)(subMD);
        };


    // listItem :: String -> String ->
    // String -> String
    const listItem = pfx =>
        // List items are bulleted unless they already
        // start with an integer string followed by a dot
        // with trailing space.
        txt => subMD => (/^\d+\.\s+/u).test(txt) ? (
            `${pfx}${txt}\n${subMD}`
        ) : `${pfx}- ${txt}\n${subMD}`;


    // lowestHeading :: String -> String -> String
    const lowestHeading = mdtxt =>
        // Any paragraphs under these headings are followed
        // (as well as preceded) by two blank lines.
        subMD => Boolean(subMD) ? (
            `${mdtxt}\n\n${subMD}\n\n`
        ) : `${mdtxt}\n\n`;


    // paragraphLine :: Bool -> String -> String -> String
    const paragraphLine = nodesAreParas =>
        // Lines of text, possibly interspersed with
        // nests of bulleted or enumerated lists.
        txt => subMD => Boolean(subMD) ? (
            // Any line descendents will be lists.
            `${txt}\n\n${subMD}\n`
            // If treated as paragraphs then \n\n delimited,
        ) : nodesAreParas ? (
            `${txt}\n\n`
            // whereas plain sentences are space-delimited.
        ) : `${txt} `;


    // ------------ MD CODE AND QUOTE BLOCKS -------------

    // mdBlockedForest :: Forest Dict -> Forest Dict
    const mdBlockedForest = forest =>
        // A copy of the list of tree nodes in which any
        // runs of quote or code nodes have been collapsed
        // to single nodes which with multiline texts.
        groupBy(
            on(a => b => a === b)(
                x => x.root.mdType
            )
        )(mdBlockTypedForest(forest))
        .flatMap(gp => {
            const typeName = gp[0].root.mdType;

            return ["code", "quote"].includes(
                typeName
            ) ? (() => {
                const
                    blockOutline = forestOutline("    ")(
                        x => x.text
                    )(gp);

                return [Node({
                    text: `\n${blockOutline}\n`,
                    mdType: typeName
                })([])];
            })() : gp;
        });


    // mdBlockTypedForest :: Forest NodeValue ->
    // Forest NodeValue
    const mdBlockTypedForest = forest => {
        // A copy of the list of tree nodes in which all
        // top level nodes in the list are flagged with
        // mdType: ("quote" | "code" | "other")
        const typedTree = ([inCode, inQuote]) => tree => {
            const
                text = tree.root.text,
                isCode = text.startsWith("```") ? (
                    !inCode
                ) : inCode,
                isQuote = isCode ? (
                    false
                ) : inQuote ? (
                    Boolean(text)
                ) : text.startsWith(">");

            return Tuple([isCode, isQuote])(
                typedNode(inCode)(isCode)(isQuote)(tree)
            );
        };

        return snd(mapAccumL(typedTree)([false, false])(
            forest
        ));
    };


    // typedNode :: Bool -> Bool -> Bool ->
    // Tree Dict -> Tree Dict
    const typedNode = inCode =>
        isCode => isQuote => tree => Node(
            Object.assign({}, tree.root, {
                mdType: (isCode || inCode) ? (
                    "code"
                ) : isQuote ? (
                    "quote"
                ) : "other"
            })
        )(tree.nest);


    // ---------------- LIBRARY FUNCTIONS ----------------

    // ----------------------- JXA -----------------------
    // https://github.com/RobTrew/prelude-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
            );
        };


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


    // confirmSavePathLR :: FilePath -> Either Message FilePath
    const confirmSavePathLR = fp => {
        const
            sa = Object.assign(Application("System Events"), {
                includeStandardAdditions: true
            }),
            pathName = splitFileName(fp),
            fldr = pathName[0];

        sa.activate();
        try {
            return Right(
                sa.chooseFileName({
                    withPrompt: "Save As:",
                    defaultName: pathName[1],
                    defaultLocation: Path(ObjC.unwrap(
                        $(
                            doesDirectoryExist(fldr) ? (
                                fldr
                            ) : "~"
                        )
                        .stringByExpandingTildeInPath
                    ))
                })
                .toString()
            );
        } catch (e) {
            return Left(e.message);
        }
    };


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

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


    // filePathFromBikeDocLR :: Bike Doc ->
    // Either String FilePath
    const filePathFromBikeDocLR = doc => {
        const file = doc.file();

        return Boolean(file) ? (
            Right(`${Path(file)}`)
        ) : Left("No filepath found – document not saved");
    };


    // outPathWithNewExtensionConfirmedLR :: String ->
    // FilePath -> Either String FilePath
    const outPathWithNewExtensionConfirmedLR = extension =>
        // Either a message or a filePath confirmed
        // by a dialog which prompts with a derived file
        // path, using a new extension.
        fp => confirmSavePathLR(
            uncurry(combine)(
                second(
                    k => `${takeBaseName(k)}.${extension}`
                )(
                    splitFileName(fp)
                )
            )
        );


    // writeFileLR :: FilePath ->
    // String -> Either String IO FilePath
    const writeFileLR = fp =>
        // Either a message or the filepath
        // to which the string has been written.
        s => {
            const
                e = $(),
                efp = $(fp).stringByStandardizingPath;

            return $.NSString.alloc.initWithUTF8String(s)
                .writeToFileAtomicallyEncodingError(
                    efp, false,
                    $.NSUTF8StringEncoding, e
                ) ? (
                    Right(ObjC.unwrap(efp))
                ) : Left(ObjC.unwrap(
                    e.localizedDescription
                ));
        };


    // --------------------- GENERIC ---------------------
    // https://github.com/RobTrew/prelude-jxa

    // 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
    });


    // Tuple (,) :: a -> b -> (a, b)
    const Tuple = a =>
        // A pair of values, possibly of
        // different types.
        b => ({
            type: "Tuple",
            "0": a,
            "1": b,
            length: 2,
            *[Symbol.iterator]() {
                for (const k in this) {
                    if (!isNaN(k)) {
                        yield this[k];
                    }
                }
            }
        });


    // bindLR (>>=) :: Either a ->
    // (a -> Either b) -> Either b
    const bindLR = m =>
        mf => m.Left ? (
            m
        ) : mf(m.Right);


    // combine (</>) :: FilePath -> FilePath -> FilePath
    const combine = fp =>
        // Two paths combined with a path separator.
        // Just the second path if that starts with
        // a path separator.
        fp1 => Boolean(fp) && Boolean(fp1) ? (
            "/" === fp1.slice(0, 1) ? (
                fp1
            ) : "/" === fp.slice(-1) ? (
                fp + fp1
            ) : `${fp}/${fp1}`
        ) : fp + fp1;


    // 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 => e.Left ? (
            fl(e.Left)
        ) : fr(e.Right);


    // enumFromTo :: Int -> Int -> [Int]
    const enumFromTo = m =>
        n => Array.from({
            length: 1 + n - m
        }, (_, i) => m + i);


    // flip :: (a -> b -> c) -> b -> a -> c
    const flip = op =>
        // The binary function op with
        // its arguments reversed.
        1 !== op.length ? (
            (a, b) => op(b, a)
        ) : (a => b => op(b)(a));


    // 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(
            root(tree)
        )(
            nest(tree).map(go)
        );

        return go;
    };


    // forestOutline :: String -> (a -> String) ->
    // Forest a -> String
    const forestOutline = indentUnit =>
        // An indented outline of the nodes
        // (each stringified by f) of a forest.
        f => forest => forest.flatMap(
            foldTree(
                x => xs => 0 < xs.length ? [
                    f(x), ...xs.flat(1)
                    .map(s => `${indentUnit}${s}`)
                ] : [f(x)]
            )
        ).join("\n");


    // groupBy :: (a -> a -> Bool) -> [a] -> [[a]]
    const groupBy = eqOp =>
        // A list of lists, each containing only elements
        // equal under the given equality operator,
        // such that the concatenation of these lists is xs.
        xs => 0 < xs.length ? (() => {
            const [h, ...t] = xs;
            const [groups, g] = t.reduce(
                ([gs, a], x) => eqOp(x)(a[0]) ? (
                    Tuple(gs)([...a, x])
                ) : Tuple([...gs, a])([x]),
                Tuple([])([h])
            );

            return [...groups, g];
        })() : [];


    // forestFromIndentedLines :: [(Int, String)] ->
    // [Tree {text:String, body:Int}]
    const forestFromIndentedLines = tuples => {
        const go = xs =>
            0 < xs.length ? (() => {
                // First line and its sub-tree,
                const [depth, body] = xs[0],
                    [tree, rest] = span(x => depth < x[0])(
                        xs.slice(1)
                    );

                return [
                        Node({
                            text: body,
                            level: depth
                        })(go(tree))
                    ]
                    // followed by the rest.
                    .concat(go(rest));
            })() : [];

        return go(tuples);
    };


    // mapAccumL :: (acc -> x -> (acc, y)) -> acc ->
    // [x] -> (acc, [y])
    const mapAccumL = f =>
        // A tuple of an accumulation and a list
        // obtained by a combined map and fold,
        // with accumulation from left to right.
        acc => xs => [...xs].reduce(
            ([a, bs], x) => second(
                v => bs.concat(v)
            )(
                f(a)(x)
            ),
            Tuple(acc)([])
        );


    // nest :: Tree a -> [a]
    const nest = tree =>
        tree.nest;


    // on :: (b -> b -> c) -> (a -> b) -> a -> a -> c
    const on = f =>
        // e.g. groupBy(on(eq)(length))
        g => a => b => f(g(a))(g(b));


    // snd :: (a, b) -> b
    const snd = tpl =>
        // Second member of a pair.
        tpl[1];


    // span :: (a -> Bool) -> [a] -> ([a], [a])
    const span = p =>
        // Longest prefix of xs consisting of elements which
        // all satisfy p, tupled with the remainder of xs.
        xs => {
            const i = xs.findIndex(x => !p(x));

            return -1 !== i ? (
                Tuple(xs.slice(0, i))(
                    xs.slice(i)
                )
            ) : Tuple(xs)([]);
        };


    // splitFileName :: FilePath -> (String, String)
    const splitFileName = strPath =>
        // Tuple of directory and file name,
        // derived from file path. (Inverse of combine).
        ("" !== strPath) ? (
            ("/" !== strPath[strPath.length - 1]) ? (() => {
                const
                    xs = strPath.split("/"),
                    stem = xs.slice(0, -1);

                return stem.length > 0 ? (
                    Tuple(
                        `${stem.join("/")}/`
                    )(xs.slice(-1)[0])
                ) : Tuple("./")(xs.slice(-1)[0]);
            })() : Tuple(strPath)("")
        ) : Tuple("./")("");


    // takeBaseName :: FilePath -> String
    const takeBaseName = fp =>
        ("" !== fp) ? (
            ("/" !== fp[fp.length - 1]) ? (() => {
                const fn = fp.split("/").slice(-1)[0];

                return fn.includes(".") ? (
                    fn.split(".").slice(0, -1)
                    .join(".")
                ) : fn;
            })() : ""
        ) : "";


    // root :: Tree a -> a
    const root = tree =>
        // The value attached to a tree node.
        tree.root;


    // second :: (a -> b) -> ((c, a) -> (c, b))
    const second = f =>
        // A function over a simple value lifted
        // to a function over a tuple.
        // f (a, b) -> (a, f(b))
        ([x, y]) => Tuple(x)(f(y));


    // uncurry :: (a -> b -> c) -> ((a, b) -> c)
    const uncurry = f =>
        // A function over a pair, derived
        // from a curried function.
        (...args) => {
            const [x, y] = Boolean(args.length % 2) ? (
                args[0]
            ) : args;

            return f(x)(y);
        };


    // zip :: [a] -> [b] -> [(a, b)]
    const zip = xs =>
        // The paired members of xs and ys, up to
        // the length of the shorter of the two lists.
        ys => Array.from({
            length: Math.min(xs.length, ys.length)
        }, (_, i) => Tuple(xs[i])(ys[i]));

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

Other Keyboard Maestro macros for BIKE Outliner

1 Like