Paste indented text as Markdown bullet nest

Text outlines copied from applications like mind-maps vary in their indentations – sometimes they are tab-indented, sometimes indented with 2 or 4 spaces, for example.

This macro aims to:

  1. read any consistent unit of indentation in a text outline in the clipboard,
  2. and paste out in the format of Markdown nested bullet lists
    • using a hyphen as the bullet character,
    • and using the standard 4-space Markdown indent.

Paste indented text as Markdown bullet nest.kmmacros (28 KB)

Expand disclosure triangle to view JS Source
/* eslint-disable max-lines-per-function */
(() => {
    "use strict";

    // Pasting any outline as nested Markdown bullet lists.
    // (with an indent unit of four spaces)

    // Rob Trew @2021

    // Ver 0.01

    ObjC.import("AppKit");

    // main :: IO ()
    const main = () =>
        either(
            // Message displayed if clipboard empty.
            alert("Outline pasted as Markdown bullets")
        )(
            // Value returned.
            x => x
        )(
            bindLR(
                clipTextLR()
            )(
                compose(
                    Right,
                    mdBulletNestFromForest,
                    forestFromIndentedLines,
                    indentLevelsFromLines,
                    lines
                )
            )
        );


    // ------------------ INDENTED TEXT ------------------

    // forestFromIndentedLines :: [(Int, String)] ->
    // [Tree String]
    const forestFromIndentedLines = tuples => {
        const go = xs =>
            0 < xs.length ? (() => {
                // First line and its sub-tree,
                const [depth, body] = Array.from(
                        xs[0]
                    ),
                    [tree, rest] = Array.from(
                        span(compose(lt(depth), fst))(
                            tail(xs)
                        )
                    );

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

        return go(tuples);
    };

    // indentLevelsFromLines :: [String] -> [(Int, String)]
    const indentLevelsFromLines = xs => {
        const
            pairs = xs.map(
                x => bimap(length)(concat)(
                    span(isSpace)(list(x))
                )
            ),
            indentUnit = pairs.reduce(
                (a, tpl) => {
                    const i = tpl[0];

                    return 0 < i ? (
                        i < a ? i : a
                    ) : a;
                },
                Infinity
            );

        return [Infinity, 0].includes(indentUnit) ? (
            pairs
        ) : pairs.map(first(n => n / indentUnit));
    };

    // --------- MARKDOWN BULLET NEST FROM TREE ----------

    // mdBulletNestFromForest :: [Tree String] -> String
    const mdBulletNestFromForest = xs => {
        const go = indent => x => {
            const
                outline = x.nest.map(
                    go(`    ${indent}`)
                );

            return [`${indent}- ${x.root}`]
                .concat(outline)
                .join("\n");
        };

        return xs.map(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
            );
        };


    // clipTextLR :: () -> Either String String
    const clipTextLR = () => (
        v => Boolean(v) && 0 < v.length ? (
            Right(v)
        ) : Left("No utf8-plain-text found in clipboard.")
    )(
        ObjC.unwrap($.NSPasteboard.generalPasteboard
            .stringForType($.NSPasteboardTypeString))
    );


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


    // Tuple (,) :: a -> b -> (a, b)
    const Tuple = a =>
        b => ({
            type: "Tuple",
            "0": a,
            "1": b,
            length: 2
        });


    // bimap :: (a -> b) -> (c -> d) -> (a, c) -> (b, d)
    const bimap = f =>
        // Tuple instance of bimap.
        // A tuple of the application of f and g to the
        // first and second values respectively.
        g => tpl => 2 !== tpl.length ? (
            // eslint-disable-next-line no-undef
            bimapN(f)(g)(tpl)
        ) : Tuple(f(tpl[0]))(
            g(tpl[1])
        );


    // bindLR (>>=) :: Either a ->
    // (a -> Either b) -> Either b
    const bindLR = m =>
        mf => m.Left ? (
            m
        ) : mf(m.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
        );


    // concat :: [[a]] -> [a]
    // concat :: [String] -> String
    const concat = xs =>
        0 < xs.length ? (
            (
                xs.every(x => "string" === typeof x) ? (
                    ""
                ) : []
            ).concat(...xs)
        ) : xs;


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


    // first :: (a -> b) -> ((a, c) -> (b, c))
    const first = f =>
        // A simple function lifted to one which applies
        // to a tuple, transforming only its first item.
        xy => {
            const tpl = Tuple(f(xy[0]))(xy[1]);

            return Array.isArray(xy) ? (
                Array.from(tpl)
            ) : tpl;
        };


    // fst :: (a, b) -> a
    const fst = tpl =>
        // First member of a pair.
        tpl[0];


    // isSpace :: Char -> Bool
    const isSpace = c =>
        // True if c is a white space character.
        (/\s/u).test(c);


    // length :: [a] -> Int
    const length = xs =>
        // Returns Infinity over objects without finite
        // length. This enables zip and zipWith to choose
        // the shorter argument when one is non-finite,
        // like cycle, repeat etc
        "GeneratorFunction" !== xs.constructor
        .constructor.name ? (
            xs.length
        ) : Infinity;


    // lines :: String -> [String]
    const lines = s =>
        // A list of strings derived from a single
        // string delimited by newline and or CR.
        0 < s.length ? (
            s.split(/[\r\n]+/u)
        ) : [];


    // list :: StringOrArrayLike b => b -> [a]
    const list = xs =>
        // xs itself, if it is an Array,
        // or an Array derived from xs.
        Array.isArray(xs) ? (
            xs
        ) : Array.from(xs || []);


    // lt (<) :: Ord a => a -> a -> Bool
    const lt = a =>
        b => a < b;


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


    // tail :: [a] -> [a]
    const tail = xs =>
        // A new list consisting of all
        // items of xs except the first.
        "GeneratorFunction" !== xs.constructor
        .constructor.name ? (
            (ys => 0 < ys.length ? ys.slice(1) : [])(
                list(xs)
            )
        ) : (take(1)(xs), xs);


    // take :: Int -> [a] -> [a]
    // take :: Int -> String -> String
    const take = n =>
        // The first n elements of a list,
        // string of characters, or stream.
        xs => "GeneratorFunction" !== xs
        .constructor.constructor.name ? (
            xs.slice(0, n)
        ) : [].concat(...Array.from({
            length: n
        }, () => {
            const x = xs.next();

            return x.done ? [] : [x.value];
        }));


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

Helped me a ton!