TP Scripting - structure-preserving mapping over a tree


#1

The standard JS Array.map applies some function to every item in a list, one by one, returning a new, transformed Array - the order and structure of which is preserved.

[1, 2, 3, 4, 5].map(x => x * 2)

// -> [2, 4, 6, 8, 10]

Here is a more generic map, which applies some function to every item in a TP outline, returning transformed/derived contents in a preserved outline structure.

// fmapTPTree :: (TPItem -> a) -> TPItem -> Tree a
const fmapTPTree = (f, tpItem) => {
    const go = x => Node(f(x),
        x.hasChildren ? (
            x.children.map(go)
        ) : []);
    return go(tpItem);
};

If we give it a function (from a TP Item to some value), and a particular TP Item, it applies the given function both to that Item, and to all of its descendants, returning a generic Tree structure, which has the same shape (same pattern of parents and children) as the TP Outline.

(Every item in the Tree is a Node, which has a root (some kind of value like a string or number or JS dictionary – created by the function which we supplied), and a nest, which is a list of child Nodes.

I personally use this generic Tree pattern as a hub format, for conversions between outlines and nested diagrams etc.

hubDiagram-002

To get from a TaskPaper outline to OPML, for example, we can:

  1. Use fmapTPTree to translate from a TaskPaper outline to the hub Tree format, and then
  2. A generic Tree -> OPML function for the second stage.

(Using a generic hub format reduces the number of translation pairs that we need to write)

Stage one (in the TaskPaper JSContext)

(Where itemTextAndTags is a function returning the details which we want for any given item)

dctTree = fmapTPTree(
    itemTextAndTags,
    editor.outline.root
)

Stage two (in the Javascript for Automation JSContext)

opmlFromTrees('Outline title', dctTree.nest)

Here is a fuller sketch, which copies the open TP Outline as OPML:

(() => {
    'use strict';

    // GENERIC TREE FROM TASKPAPER -------------------------------------------

    const tpJSContext = (editor, options) => {

        // GENERIC FUNCTIONS FOR TP CONTEXT ----------------------------------

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

        // fmapTPTree :: (TPItem -> a) -> TPItem -> Tree a
        const fmapTPTree = (f, tpItem) => {
            const go = x => Node(f(x),
                x.hasChildren ? (
                    x.children.map(go)
                ) : []);
            return go(tpItem);
        };

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

        // TP CONTEXT MAIN ---------------------------------------------------

        // itemTextAndTags :: TPItem ->
        //        { text :: String, kvs :: [(String, String))] }
        const itemTextAndTags = tpItem => {
            const attribs = tpItem.attributes;
            return {
                text: tpItem.bodyContentString,
                kvs: tpItem.attributeNames.reduce(
                    (a, k) => [ // Tags and values except 'type' + 'indent'.
                        'indent', 'data-type'
                    ].includes(k) ? (
                        a // Unmodified accumulator
                    ) : a.concat([
                        [ // Key-value pair added to accumulator
                            k.startsWith('data-') ? (
                                k.slice(5) // 'data-' prefix dropped
                            ) : k, attribs[k]
                        ]
                    ]), []
                )
            }
        };

        return showJSON(
            fmapTPTree(
                itemTextAndTags,
                editor.outline.root
            )
        );
    };

    // GENERIC FUNCTIONS FOR JXA CONTEXT -------------------------------------

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

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

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

    // intercalate :: [a] -> [[a]] -> [a]
    // intercalate :: String -> [String] -> String
    const intercalate = (sep, xs) =>
        concat(intersperse(sep, xs));

    // intersperse(0, [1,2,3]) -> [1, 0, 2, 0, 3]
    // intersperse :: Char -> String -> String
    // intersperse :: a -> [a] -> [a]
    const intersperse = (sep, xs) => {
        const bool = (typeof xs)[0] === 's';
        return xs.length > 1 ? (
            (bool ? concat : x => x)(
                (bool ? (
                    xs.split('')
                ) : xs)
                .slice(1)
                .reduce((a, x) => a.concat([sep, x]), [xs[0]])
            )) : xs;
    };

    // 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.' (See 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, []));

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

    // OPML FROM GENERIC TREE ------------------------------------------------

    // opmlFromTrees :: String -> [Tree] -> 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 intercalate(
                    ',',
                    indexAndMax(0, xs)[1].map(x => x.toString())
                );
            };

        // nodeOPML :: String -> Node -> String
        const nodeOPML = indent => x => {
            const
                root = x.root,
                nest = x.nest;
            return indent + '<outline ' + unwords(map(
                ([k, v]) => k + '="' + entCoded(v) + '"',
                cons(['text', root.text], root.kvs)
            )) + (nest.length > 0 ? (
                '>\n' +
                unlines(map(nodeOPML(indent + '    '), 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(nodeOPML('    '), xs), [
                '  </body>',
                '</opml>'
            ]
        ]));
    };

    // JXA CONTEXT -------------------------------------------------------

    // Left :: a -> Either a b
    const Left = x => ({
        type: 'Either',
        Left: x
    });

    // Right :: b -> Either a b
    const Right = x => ({
        type: 'Either',
        Right: x
    });

    // bindLR (>>=) :: Either a -> (a -> Either b) -> Either b
    const bindLR = (m, mf) =>
        m.Right !== undefined ? (
            mf(m.Right)
        ) : m;

    // standardAdditions :: () -> Application
    const standardAdditions = () =>
        Object.assign(Application.currentApplication(), {
            includeStandardAdditions: true
        });

    const
        ds = Application('TaskPaper')
        .documents,
        lrResult = bindLR(
            bindLR(
                bindLR(
                    ds.length > 0 ? Right(ds.at(0)) : Left(
                        'No documents open'
                    ),
                    d => Right(d.evaluate({
                        script: tpJSContext.toString(),
                        withOptions: {}
                    }))
                ),
                strJSON => {
                    let dctTree = {};
                    try {
                        dctTree = JSON.parse(strJSON);
                    } catch (e) {
                        return Left(e.message)
                    }
                    return Right(
                        opmlFromTrees('Outline title', dctTree.nest)
                    );
                }
            ),
            strOPML => (
                standardAdditions()
                .setTheClipboardTo(strOPML),
                Right(strOPML)
            )
        );

    return lrResult.Right || lrResult.Left;
})();