Script :: Copy As bullet-free (rich text) Apple Mail outline

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

This variant Copy As copies the Bike outline, with all its inline formatting, in Apple Mail’s native bullet-free outline format, which responds, in Mail, to:

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

To test the script below in Script Editor, set the language selector at top left to JavaScript.

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

See: Using Scripts - Bike


For a Keyboard Maestro version, binding the script, by default, to ^⇧C, see:

3 Likes

Thanks! I’ve been hoping for this…

2 Likes