Copy Selected Lines From BIKE for Mail-Indented Pasting

Plain (tab or space)-indented outlines pasted into Apple Mail don't respond to Mail's:

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

This macro copies an outline selected in Hog Bay's Bike Outliner to Mail's own indentation format, for direct pasting into Mail.app.


Copy selected lines from BIKE for Mail-indented pasting.kmmacros (8.4 KB)

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

    ObjC.import("AppKit");

    // Draft of "Copy As Mail.app indented"
    // for Bike 1.1

    // Rob Trew @2022
    // Ver 0.03

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

        return either(
            alert("Copy as Mail.app indentation")
        )(
            x => x
        )(
            doc.exists() ? (() => {
                const
                    selectedRows = doc.rows.where({
                        selected: true
                    }),
                    n = selectedRows.length,
                    levels = selectedRows.level(),
                    minLevel = Math.min(...levels);

                return (
                    setClipOfTextType("public.html")(
                        mailQuoteBlocksFromForest(
                            forestFromIndentedLines(
                                zip(
                                    levels.map(x => x - minLevel)
                                )(
                                    selectedRows.name()
                                )
                            )
                        )
                    ),
                    Right(
                        `${n} row(s) copied as Mail.app indented lines.`
                    )
                );
            })() : Left("No document open in Bike")
        );
    };

    // ------- INDENTED MAIL.APP HTML FROM FOREST --------

    // mailQuoteBlocksFromForest :: [Tree String] -> String
    const mailQuoteBlocksFromForest = forest => {
        const
            style = "style=\"margin: 0px 0px 0px 40px;\"",
            go = tree => {
                const xs = tree.nest;

                return [
                        `<div>${tree.root || "&nbsp;"}</div>`,
                        ...(
                            0 < xs.length ? [
                                [
                                    `<blockquote ${style}>`,
                                    xs.flatMap(go).join("\n"),
                                    "</blockquote>"
                                ]
                                .join("\n")
                            ] : []
                        )
                    ]
                    .join("\n");
            };

        return forest.flatMap(go).join("\n");
    };


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


    // Right :: b -> Either a b
    const Right = x => ({
        type: "Either",
        Right: 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);


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


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


    // forestFromIndentedLines :: [(Int, String)] ->
    // [Tree String]
    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)
                    );

                // followed by the rest.
                return [
                    Node(body)(go(tree))
                ].concat(go(rest));
            })() : [];

        return go(tuples);
    };


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


    // 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

3 Likes

For a newer version, which copies rich text formatting, as well as Mail's native bullet-free outline indentation, see:

2 Likes