Copy a tab-indented outline as OPML

A macro to:

  • copy the selected lines of a (tab-indented) text outline as OPML,
  • optionally saving them to an opml file.

The default setting it to prompt the user for a file path, and save the OPML.

To disable the prompt and file save, (so that the macro simply copies from a selected text outline, placing OPML source in the clipboard), change the value of the saveAsOPML KM variable in the macro from true to false.

Copy a TAB-indented outline as OPML.kmmacros (34.9 KB)

opmlFromTabbedText

Javascript source (Tabbed text to OPML):

(() => {
    'use strict';

    // MAIN -----------------------------------------------

    // main :: KM IO () -> OPMLString
    const main = () =>
        opmlFromTrees('Translation from tab indents',
            treesFromLineIndents(
                indentedLines(
                    Application('Keyboard Maestro Engine')
                    .getvariable('tabOutline')
                )
            )
        );

    // GENERIC FUNCTIONS --------------------------------------

    // Node :: a -> [Tree a] -> Tree a
    const Node = (v, xs) => ({
        type: 'Node',
        root: v, // any type of value (but must be consistent across tree)
        nest: xs || []
    });

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

    // concat :: [[a]] -> [a]
    // concat :: [String] -> String
    const concat = xs =>
        xs.length > 0 ? (() => {
            const unit = typeof xs[0] === 'string' ? '' : [];
            return unit.concat.apply(unit, xs);
        })() : [];

    // concatMap :: (a -> [b]) -> [a] -> [b]
    const concatMap = (f, xs) => [].concat.apply([], xs.map(f));

    // cons :: a -> [a] -> [a]
    const cons = (x, xs) => [x].concat(xs);

    // filter :: (a -> Bool) -> [a] -> [a]
    const filter = (f, xs) => xs.filter(f);

    // foldl :: (a -> b -> a) -> a -> [b] -> a
    const foldl = (f, a, xs) => xs.reduce(f, a);

    // foldl1 :: (a -> a -> a) -> [a] -> a
    const foldl1 = (f, xs) =>
        xs.length > 1 ? xs.slice(1)
        .reduce(f, xs[0]) : xs[0];

    // fst :: (a, b) -> a
    const fst = tpl => tpl[0];

    // isNull :: [a] -> Bool
    // isNull :: String -> Bool
    const isNull = xs =>
        Array.isArray(xs) || typeof xs === 'string' ? (
            xs.length < 1
        ) : undefined;

    // iterateUntil :: (a -> Bool) -> (a -> a) -> a -> [a]
    const iterateUntil = (p, f, x) => {
        let vs = [x],
            h = x;
        while (!p(h))(h = f(h), vs.push(h));
        return vs;
    };

    // levels :: Tree a -> [[a]]
    const levels = tree =>
        map(xs => map(x => x.root, xs),
            iterateUntil(
                xs => xs.length < 1,
                xs => concatMap(x => x.nest, xs), [tree]
            )
        );

    // lines :: String -> [String]
    const lines = s => s.split(/[\r\n]/);

    // map :: (a -> b) -> [a] -> [b]
    const map = (f, xs) => xs.map(f);

    // 'The mapAccumL function behaves like a combination of map and foldl;
    // it applies a function to each element of a list, passing an accumulating
    // parameter from left to right, and returning a final value of this
    // accumulator together with the new list.' (GHC via Hoogle)

    // mapAccumL :: (acc -> x -> (acc, y)) -> acc -> [x] -> (acc, [y])
    const mapAccumL = (f, acc, xs) =>
        xs.reduce((a, x, i) => {
            const pair = f(a[0], x, i);
            return Tuple(pair[0], a[1].concat(pair[1]));
        }, Tuple(acc, []));

    // minimum :: Ord a => [a] -> a
    const minimum = xs =>
        xs.length > 0 ? (
            foldl1((a, x) => x < a ? x : a, xs)
        ) : undefined;

    // quot :: Int -> Int -> Int
    const quot = (n, m) => Math.floor(n / m);

    // showJSON :: a -> String
    const showJSON = x => JSON.stringify(x, null, 2);

    // unlines :: [String] -> String
    const unlines = xs => xs.join('\n');

    // unwords :: [String] -> String
    const unwords = xs => xs.join(' ');

    // words :: String -> [String]
    const words = s => s.split(/\s+/);

    // zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
    const zipWith = (f, xs, ys) =>
        Array.from({
            length: Math.min(xs.length, ys.length)
        }, (_, i) => f(xs[i], ys[i], i));


    // READING TABBED TEXT ----------------------------

    // indentedLines :: String -> [(Int, String)]
    const indentedLines = strIndented => {
        const
            rgxIndent = /^(\s*)(.*)$/,
            xs = concatMap(
                s => {
                    const
                        lr = s.match(rgxIndent).slice(1),
                        w = lr[1];
                    return isNull(w) ? (
                        []
                    ) : [Tuple(lr[0].length, w)];
                },
                lines(strIndented)
            ),
            indents = concatMap(x => {
                const n = x[0];
                return n > 0 ? [n] : [];
            }, xs),
            base = minimum(indents),
            unit = minimum(
                filter(x => x > 0,
                    mapAccumL(
                        (a, x) => Tuple(x, Math.abs(x - a)),
                        0, indents
                    )[1]
                )
            );
        return unit > 1 ? map(
            x => Tuple(
                Math.floor((x[0] - base) / unit),
                x[1]
            ),
            xs
        ) : xs;
    };
    // treesFromLineIndents :: [(Int, String)] -> [Node String]
    const treesFromLineIndents = xs =>
        foldl((levels, tpl) => {
                const
                    indent = tpl[0],
                    iNext = indent + 1,
                    iMax = levels.length - 1,
                    node = Node({
                        text: tpl[1],
                        level: tpl[0]
                    })
                return (
                    levels[
                        indent < iMax ? (
                            indent
                        ) : iMax
                    ].nest.push(node),
                    iNext > iMax ? (
                        levels.push(node)
                    ) : levels[iNext] = node,
                    levels
                );
            }, [Node(undefined, [])],
            xs
        )[0].nest;

    // WRITING OPML -----------------------------------

    // opmlFromTrees :: String -> [Node] -> OPML String
    const opmlFromTrees = (strTitle, xs) => {
        const
            // ents :: [(Regex, String)]
            ents = zipWith.apply(null,
                cons(
                    (x, y) => [new RegExp(x, 'g'), '&' + y + ';'],
                    map(words, ['& \' " < >', 'amp apos quot lt gt'])
                )
            ),

            // entCoded :: a -> String
            entCoded = v => ents.reduce(
                (a, [x, y]) => a.replace(x, y),
                v.toString()
            ),

            // Nest -> Comma-delimited row indices of all parents in tree
            // expands :: [textNest] -> String
            expands = xs => {
                const indexAndMax = (n, xs) =>
                    mapAccumL((m, node) =>
                        node.nest.length > 0 ? (() => {
                            const sub = indexAndMax(m + 1, node.nest);
                            return [sub[0], cons(m, concat(sub[1]))];
                        })() : [m + 1, []], n, xs);
                return concat(indexAndMax(0, xs)[1]).join(',');
            };

        // tnOPML :: String -> Dict -> String
        const tnOPML = indent => node => {
            const root = node.root || {};
            return indent + '<outline ' + unwords(map(
                ([k, v]) => k + '="' + entCoded(v) + '"',
                cons(['text', root.text || ''], node.kvs || [])
            )) + (node.nest.length > 0 ? (
                '>\n' +
                unlines(map(tnOPML(indent + '    '), node.nest)) +
                '\n' +
                indent + '</outline>'
            ) : '/>');
        };

        // OPML serialization -----------------------------
        return unlines(concat([
            [
                '<?xml version=\"1.0\" encoding=\"utf-8\"?>',
                '<opml version=\"2.0\">',
                '  <head>',
                '    <title>' + strTitle + '</title>',
                '    <expansionState>' + expands(xs) +
                '</expansionState>',
                '  </head>',
                '  <body>'
            ],
            map(tnOPML('    '), xs), [
                '  </body>',
                '</opml>'
            ]
        ]));
    };

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