TaskPaper ⇄ Tinderbox import export

Here, in the meanwhile (for discussion and adjustment) is a first draft of a fairly general Copy As TaskPaper script for Tinderbox 8.

This draft takes the approach of copying all key attributes for each item, (as TaskPaper tags) except where they have only a default value for that item.

(() => {
    'use strict';

    ObjC.import('AppKit');

    // Copy As TaskPaper for Tinderbox 8
    // Draft 0.6

    // Copies either selected notes (with their descendants)
    // or all notes if there is no selection.

    // All key attributes with non-default values are copied
    // as TaskPaper tags for each note.

    // The plain text of any $Text value is appended to an item
    // as a TaskPaper note. (i.e. with additional indentation)

    // Date strings are normalized to the TaskPaper pattern.

    // main :: IO ()
    const main = () => (
        tbxDocs => either(
            alert('Copy as TaskPaper') // If there is a problem.
        )(
            copyText // Otherwise copy as TaskPaper text.
        )(
            bindLR(
                0 < tbxDocs.length ? (
                    Right(tbxDocs.at(0))
                ) : Left('No documents open in Tinderbox.')
            )(
                tbxDoc => {
                    const
                        forest = map(
                            fmapPureTreeTBX(nameTextAndAttributeDict)
                        )(allOrSelectedTrees(tbxDoc)),
                        keyAttribs = allKeyAttributes(tbxDoc)(
                            forest
                        );
                    return Right(unlines(
                        indentedLinesFromForest('\t')(
                            tp3Item(keyAttribs)
                        )(forest)
                    ));
                }
            )
        )
    )(
        Application('Tinderbox 8').documents
    );

    // ------------- TASKPAPER OUTPUT FORMAT --------------

    // iso8601Local :: Date -> String
    const iso8601Local = dte =>
        new Date(dte - (6E4 * dte.getTimezoneOffset()))
        .toISOString();

    // tpDateString :: Date -> String
    const tpDateString = dte => {
        const [d, t] = iso8601Local(new Date(dte)).split('T');
        return [d, t.slice(0, 5)].join(' ');
    };

    // tp3Item :: Dict -> String -> Dict -> [String]
    const tp3Item = keyAttribs =>
        strIndent => dctNote => (
            (blnProject, tags, strText) => [
                (
                    blnProject ? (
                        dctNote.name + ':'
                    ) : (strIndent + '- ' + dctNote.name)
                ) + (
                    0 < tags.length ? (
                        ' ' + tags.join(' ')
                    ) : ''
                )
            ].concat(
                0 < strText.length ? (
                    indented(strText)
                )(strIndent + '\t') : []
            )
        )(
            0 === strIndent.length,
            dctNote.keyAttribs.flatMap(
                tp3Tag(keyAttribs)(dctNote.attribs)
            ),
            dctNote.text
        );


    // tp3Name :: String -> String
    const tp3Name = k =>
        toLower(k[0]) + k.slice(1);

    // tp3TypeString :: String -> String -> String
    const tp3TypeString = strType =>
        strValue => 'date' === strType ? (
            tpDateString(strValue)
        ) : strValue;

    // tp3Tag :: Dict -> TBX attributes -> String -> [String]
    const tp3Tag = docKeyAttribs =>
        noteAttribs => k => (
            (attrib, v) => {
                const t = attrib.type;
                return attrib.default !== v ? (
                    'boolean' !== t ? (
                        [`@${tp3Name(k)}(${tp3TypeString(t)(v)})`]
                    ) : ['@' + tp3Name(k)]
                ) : []
            })(
            docKeyAttribs[k],
            noteAttribs.byName(k).value()
        );

    // ------------------ TINDERBOX -------------------

    // allKeyAttributes :: TBX Doc -> [Tree] ->
    // [{ name :: String,
    //    attribs :: TBX attributes,
    //    keyAttribs :: [String]}]
    const allKeyAttributes = doc =>
        forest => (
            docAttribs => Array.from(
                new Set(
                    forest.flatMap(
                        foldTree(
                            x => xs => x.keyAttribs.concat(
                                concat(xs)
                            )
                        )
                    )
                )
            ).reduce((a, k) => (
                attrib => 0 < k.length ? (
                    Object.assign(
                        a, {
                            [k]: {
                                type: attrib.type(),
                                default: attrib.defaultvalue()
                            }
                        }
                    )
                ) : a
            )(docAttribs.byName(k)), {})
        )(doc.attributes);


    // allOrSelectedTrees :: TBX Doc -> [TBX Note]
    const allOrSelectedTrees = doc =>
        // The top-level notes of the selection, or of
        // the whole document if there is no selection.
        tbxCommonAncestors((
            selns => 0 < selns.length ? (
                selns
            ) : doc.notes()
        )(
            doc.selections()
        ));


    // attribVal :: String -> TBX Note -> String
    const attribVal = k =>
        // The value of a named Attribute
        // for a given note.
        note => note.attributeOf({
            named: k
        }).value();


    // fmapPureTreeTBX :: (TBXNote -> a)  ->
    // TBXNote  -> Tree TBXNote
    const fmapPureTreeTBX = f => {
        // Generic Tree model of a Tinderbox
        // note and its descendants.
        // Node contents are defined by the
        // application of a function f to each note.
        const go = x =>
            Node(f(x))(
                x.notes().map(go)
            );
        return go;
    };


    // nameTextAndAttributeDict :: TBX Note -> Dict
    const nameTextAndAttributeDict = x => (
        strKeyAttribs => ({
            name: x.name(),
            text: x.text(),
            attribs: x.attributes,
            keyAttribs: 0 < strKeyAttribs.length ? (
                strKeyAttribs.split(';')
            ) : []
        })
    )(attribVal('KeyAttributes')(x));


    // tbxCommonAncestors :: [TBX Notes] -> Set TBX Notes
    const tbxCommonAncestors = notes => {
        // A set of Tinderbox notes filtered to exclude
        // those which descend from other items in
        // the input list.
        const go = dicts =>
            2 > dicts.length ? (
                dicts
            ) : ((dct, path) => [dct].concat(go(
                dicts.slice(1).filter(
                    x => !x.path.startsWith(path)
                )
            )))(dicts[0], dicts[0].path),
            ancestors = new Set(go(
                sortBy(comparing(x => x.path))(
                    notes.map(x => ({
                        note: x,
                        path: attribVal('Path')(x)
                    }))
                )
            ).map(x => x.note));
        return notes.filter(x => ancestors.has(x))
    };


    // ---------------- GENERIC TREES -----------------

    // indentedLinesFromForest :: String ->
    // (String -> a -> [String]) -> [Tree a] -> [String]
    const indentedLinesFromForest = strTab =>
        // Indented text representation of a list of Trees.
        // f is an (a -> String) function defining
        // the string representation of tree nodes.
        f => trees => {
            const go = indent =>
                node => f(indent)(node.root)
                .concat(node.nest.flatMap(
                    go(strTab + indent)
                ));
            return trees.flatMap(go(''));
        };


    // ----------------------- JXA ------------------------

    // alert :: String => String -> IO String
    const alert = title => s => (
        sa => (
            sa.activate(),
            sa.displayDialog(s, {
                withTitle: title,
                buttons: ['OK'],
                defaultButton: 'OK',
                withIcon: sa.pathToResource('TaskPaper.icns', {
                    inBundle: 'Applications/TaskPaper.app'
                })
            }),
            s
        )
    )(
        Object.assign(Application('System Events'), {
            includeStandardAdditions: true
        })
    );


    // copyText :: String -> IO String
    const copyText = s => (
        pb => (
            pb.clearContents,
            pb.setStringForType(
                $(s),
                $.NSPasteboardTypeString
            ),
            s
        )
    )($.NSPasteboard.generalPasteboard);


    // GENERIC FUNCTIONS ----------------------------
    // https://github.com/RobTrew/prelude-jxa

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


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


    // comparing :: (a -> b) -> (a -> a -> Ordering)
    const comparing = f =>
        x => y => {
            const
                a = f(x),
                b = f(y);
            return a < b ? -1 : (a > b ? 1 : 0);
        };


    // concat :: [[a]] -> [a]
    // concat :: [String] -> String
    const concat = xs => (
        ys => 0 < ys.length ? (
            ys.every(Array.isArray) ? (
                []
            ) : ''
        ).concat(...ys) : ys
    )(list(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 => 'Either' === e.type ? (
            undefined !== e.Left ? (
                fl(e.Left)
            ) : fr(e.Right)
        ) : undefined;


    // foldTree :: (a -> [b] -> b) -> Tree a -> b
    const foldTree = f => {
        // The catamorphism on trees. A summary
        // value obtained by a depth-first fold.
        const go = tree => f(tree.root)(
            tree.nest.map(go)
        );
        return go;
    };


    // indented :: String -> String -> [String]
    const indented = s =>
        strIndent => s.split(/[\r\n]/).map(
            x => '' !== x ? strIndent + x : x
        );


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


    // map :: (a -> b) -> [a] -> [b]
    const map = f =>
        // The list obtained by applying f
        // to each element of xs.
        // (The image of xs under f).
        xs => xs.map(f);


    // showLog :: a -> IO ()
    const showLog = (...args) =>
        console.log(
            args
            .map(JSON.stringify)
            .join(' -> ')
        );


    // sortBy :: (a -> a -> Ordering) -> [a] -> [a]
    const sortBy = f =>
        xs => list(xs).slice()
        .sort((a, b) => f(a)(b));


    // toLower :: String -> String
    const toLower = s =>
        // Lower-case version of string.
        s.toLocaleLowerCase();


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