Bike Outliner – Copy As bullet-free (rich text) Apple Mail outline

A macro for Jesse Grosjean's Bike Outliner.

By default, outlines copied from Bike are pasted into Apple Mail with bullets.

This variant 'Copy As' for Bike copies the outline (with rich-text inline formatting) in Apple Mail's native bullet-free outline format, which responds to Mail's:

  • Format > Indentation > Increase ⌘]
  • Format > Indentation > Decrease ⌘[

BIKE - Copy as bullet-free (rich text) Apple Mail outline.kmmacros (29 KB)


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

    // Bike Outliner script :: selected rows copied as
    // Apple Mail bullet-free rich text outline.

    // Uses Mail indentation as in:
    //     Format > Indentation > Increase ( ⌘] )
    //     Format > Indentation > Decrease ( ⌘[ )

    // Rob Trew @2022
    // Ver 0.02

    ObjC.import("AppKit");

    const unWrap = ObjC.unwrap;

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

        return either(
            alert("Copy As bullet-free Mail outline")
        )(
            compose(
                () => "Copied as bullet-free Mail outline.",
                setClipOfTextType("public.html")
            )
        )(
            doc.exists() ? (
                fmapLR(topLevelRowsInParse)(
                    dictFromHTML(
                        doc.export({
                            from: doc.rows.where({
                                selected: true
                            }),
                            as: "bike format",
                            all: false
                        })
                    )
                )
            ) : Left("No documents open in Bike")
        );
    };

    // ------------- APPLE MAIL HTML OUTLINE -------------

    // divBlockHTML :: [Node Dict] -> String
    const divBlockHTML = xs => {
        // go :: Dict -> [String]
        const go = liDict => {
            const
                pairOrSingle = liDict.nest,
                xml = pairOrSingle[0].root.xml,
                div = `<div>${xml}</div>`;

            return 1 < pairOrSingle.length ? [
                div,
                (() => {
                    const
                        tag = "blockquote",
                        subTree = divBlockHTML(
                            pairOrSingle[1].nest
                        );

                    return `<${tag}>${subTree}</${tag}>`;
                })()
            ] : [div];
        };

        return unlines(xs.flatMap(go));
    };

    // ----------------- XML PARSE TREE ------------------

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

        return Boolean(error.code) ? (
            Left("Not parseable as XML: " + (
                `${html}`
            ))
        ) : Right(xmlNodeDict(node));
    };

    // topLevelRowsInParse :: Dict -> Tree Dict
    const topLevelRowsInParse = dict =>
    // Subforest of the XML parse tree
    // corresponding to top-leveloutline rows.
        divBlockHTML(
            // parse.html.head.body.rootUL.nest
            dict.nest[0].nest[1].nest[0].nest
        );

    // xmlNodeDict :: NSXMLNode -> Node Dict
    const xmlNodeDict = xmlNode => {
        const
            hasChildren = 0 < parseInt(
                xmlNode.childCount, 10
            );

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

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

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


    // setClipOfTextType :: String -> String -> IO String
    const setClipOfTextType = utiOrBundleID =>
        txt => {
            const pb = $.NSPasteboard.generalPasteboard;

            return (
                pb.clearContents,
                pb.setStringForType(
                    $(txt),
                    utiOrBundleID
                ),
                txt
            );
        };

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


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


    // fmapLR (<$>) :: (b -> c) -> Either a b -> Either a c
    const fmapLR = f =>
        // Either f mapped into the contents of any Right
        // value in e, or e unchanged if is a Left value.
        e => "Left" in e ? (
            e
        ) : Right(f(e.Right));


    // unlines :: [String] -> String
    const unlines = xs =>
    // A single string formed by the intercalation
    // of a list of strings with the newline character.
        xs.join("\n");


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

Other Keyboard Maestro macros for BIKE Outliner

2 Likes

Note, FWIW that a similar macro (for earlier versions of Bike)

Copy selected lines from BIKE for Mail-indented pasting

copied the outline without inline formatting like Bold, Italic, Highlight, Code etc,
whereas this newer version aims to preserve all inline formatting.

2 Likes