TaskPaper ⇄ Tinderbox import export

And from the same tree as above, we could obtain a list of names by writing:

foldTree(
    x => xs => x.name + '\n' + concat(xs)
)(dictTree);

So this script:

(() => {
    'use strict';

    // main :: IO ()
    const main = () => {

        const dictTree = Node({
            name: 'alpha',
            n: 34
        })([
            Node({
                name: 'child 1',
                n: 26
            })([
                Node({
                    name: 'grandchild A',
                    n: 89
                })([]),
                Node({
                    name: 'grandchild B',
                    n: 42
                })([]),
                Node({
                    name: 'grandchild B',
                    n: 73
                })([]),
            ]),
            Node({
                name: 'child 2',
                n: 16
            })([]),
            Node({
                name: 'child 3',
                n: 12
            })([]),
        ]);

        return foldTree(
            x => xs => x.name + '\n' + concat(xs)
        )(dictTree);
    };

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

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

    // concat :: [[a]] -> [a]
    // concat :: [String] -> String
    const concat = xs => (
        ys => 0 < ys.length ? (
            ys.every(Array.isArray) ? (
                []
            ) : ''
        ).concat(...ys) : ys
    )([...xs]);

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

    // sum :: [Num] -> Num
    const sum = xs =>
        // The numeric sum of all values in xs.
        xs.reduce((a, x) => a + x, 0);

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

reduces to:

alpha
child 1
grandchild A
grandchild B
grandchild B
child 2
child 3

So if we want to obtain a thesaurus of:

  1. all the tags used in the front TaskPaper document
  2. with a listing, for each tag, of all the @key(value) held by that tag in this document,

we could do it in two stages,

  1. capture the whole TaskPaper outline into a generic list of [Tree Dict] structures (a list because there may well be more than one top level paragraph, so an outline will usually be a forest of several trees, rather than a single tree.
  2. use a generic helper function like foldTree with a specialised function which gathers tags and values.

First, capturing the front document as a generic forest of dictionaries, in which each dictionary has a name key (the main text of the line), and also one additional key for each tag in that line:

(() => {
    'use strict';

    ObjC.import('AppKit');

    const main = () => {

        // Ver 3: Reading attribute names and strings, with body text.

        const tp3Context = (editor, options) => {
            const tp3Main = () =>
                editor.outline.root.children.map(
                    // Reading text and also named attribute key/values.
                    fmapPureTP(x => {
                        const
                            dct = x.attributes,
                            dateParse = DateTime.parse,
                            unixEpoch = dateParse('1970');
                        return Object.keys(dct).reduce(
                            (a, k) => k.startsWith(
                                'data-'
                            ) && k !== 'data-type' ? (
                                Object.assign(a, {
                                    [k.slice(5)]: (
                                        v => (v && (unixEpoch < v)) ? (
                                            v
                                        ) : dct[k]
                                    )(
                                        0 < dct[k].length ? (
                                            // Is the tag value
                                            // parseable as a date ?
                                            dateParse(dct[k])
                                        ) : ''
                                    )
                                })
                            ) : a, {
                                name: x.bodyContentString
                            }
                        );
                    })
                );

            // ------ GENERICS FOR TASKPAPER CONTEXT ------

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

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

            return tp3Main();
        };


        // ----- MACOS AUTOMATION JS CONTEXT ('JXA') ------
        const
            ds = Application('TaskPaper')
            .documents;
        return either(alert('TaskPaper to Tinderbox'))(
            // JSON copied to the clipboard.
            x => JSON.stringify(x, null, 2)
        )(
            bindLR(
                ds.length > 0 ? (
                    Right(ds.at(0))
                ) : Left('No TaskPaper documents open')
            )(doc => {
                // ----------- TASKPAPER VALUES -----------
                const
                    forest = doc.evaluate({
                        script: tp3Context.toString(),
                        withOptions: {
                            optionName: 'someValue'
                        }
                    });

                return Right(forest)
            })
        );
    };

    // ------------------ JXA PRIMITIVES ------------------

    // 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 => {
        // String copied to general pasteboard.
        const pb = $.NSPasteboard.generalPasteboard;
        return (
            pb.clearContents,
            pb.setStringForType(
                $(s),
                $.NSPasteboardTypeString
            ),
            s
        );
    };

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

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

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

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

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

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

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

And a use of foldTree to apply a consolidatedThesaurus function across the tree, might look like the script below.

The thesaurus consolidation is defined in terms of:

  • Array.reduce which gives a sum or other accumulation over the values in a list
  • Object.keys which returns a list of the keys in a dictionary
  • Object.assign which adds one or more new key-value pairs to an existing dictionary

(all useful standard JS methods)

before we go on to produce tag value type guesses from a thesaurus of the values for each tag, it may be worth pausing to make sure that Array.reduce is more or less comprehensible.

In the meanwhile, here is an example of foldTree(consolidatedThesaurus) over a [Tree Dict] structure:

(() => {
    'use strict';

    ObjC.import('AppKit');

    const main = () => {

        // Obtaining a thesaurus of tag values for the front
        // TaskPaper document.

        const tp3Context = (editor, options) => {
            const tp3Main = () =>
                editor.outline.root.children.map(
                    // Reading text and also named attribute key/values.
                    fmapPureTP(x => {
                        const
                            dct = x.attributes,
                            dateParse = DateTime.parse,
                            unixEpoch = dateParse('1970');
                        return Object.keys(dct).reduce(
                            (a, k) => k.startsWith(
                                'data-'
                            ) && k !== 'data-type' ? (
                                Object.assign(a, {
                                    [k.slice(5)]: (
                                        v => (v && (unixEpoch < v)) ? (
                                            v
                                        ) : dct[k]
                                    )(
                                        0 < dct[k].length ? (
                                            // Is the tag value
                                            // parseable as a date ?
                                            dateParse(dct[k])
                                        ) : ''
                                    )
                                })
                            ) : a, {
                                name: x.bodyContentString
                            }
                        );
                    })
                );

            // ------ GENERICS FOR TASKPAPER CONTEXT ------

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

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

            return tp3Main();
        };


        // ----- MACOS AUTOMATION JS CONTEXT ('JXA') ------
        const
            ds = Application('TaskPaper')
            .documents;
        return either(alert('TaskPaper to Tinderbox'))(
            // JSON copied to the clipboard.
            x => JSON.stringify(x, null, 2)
        )(
            bindLR(
                ds.length > 0 ? (
                    Right(ds.at(0))
                ) : Left('No TaskPaper documents open')
            )(doc => {
                // ----------- TASKPAPER VALUES -----------
                const
                    forest = doc.evaluate({
                        script: tp3Context.toString(),
                        withOptions: {
                            optionName: 'someValue'
                        }
                    }),
                    tagThesaurus = foldTree(consolidatedThesaurus)(
                        Node({})(forest)
                    );

                return Right(tagThesaurus)
            })
        );
    };

    // -------------------- TASKPAPER ---------------------

    // consolidatedThesaurus :: Dict -> [Dict] -> Dict
    const consolidatedThesaurus = tagDict =>
        subTreeThesauri => subTreeThesauri.reduce(
            // The value of each attribute, appended to a
            // thesaurus of any other values seen for that
            // attribute in the current tree.
            (a, attribs) => Object.assign(
                a,
                Object.keys(attribs).reduce(
                    (b, k) => {
                        const
                            // Value in this instance of the tag.
                            v = attribs[k],
                            // Values seen so far for this tag.
                            values = a[k] || [];
                        return values.includes(v[0]) ? (
                            b // No change. This value has already been seen.
                        ) : Object.assign(b, {
                            // New value for this tag.
                            // Added here to the thesaurus.
                            [k]: values.concat(v)
                        })
                    }, {}
                )
            ),
            // A new object containing each attribute
            // (other than 'name') of the current node,
            // with the value of each attribute,
            // wrapped in a list, so that any other values
            // seen for the same tag can be appended.
            Object.keys(tagDict).reduce(
                (a, k) => 'name' !== k ? (
                    Object.assign(a, {
                        [k]: [tagDict[k]]
                    })
                ) : a, {} // Empty object to serve as accumulator.
            )
        );

    // ------------------ JXA PRIMITIVES ------------------

    // 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 => {
        // String copied to general pasteboard.
        const pb = $.NSPasteboard.generalPasteboard;
        return (
            pb.clearContents,
            pb.setStringForType(
                $(s),
                $.NSPasteboardTypeString
            ),
            s
        );
    };

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

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

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

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


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


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

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

If Array.reduce is making sense, then we can define a guess at the type of a TaskPaper tag (from the thesaurus of values seen with it), by writing

// typeGuessFromValueStrings :: [String] -> String
const typeGuessFromValueStrings = xs =>
    // A guessed type-name drawn from:
    // {'boolean', 'date', 'number', 'list', 'string'}
    0 === xs.join('').length ? (
        'boolean'
    ) : (
        ds => ds.some(x => isNaN(x)) ? (
            xs.some(x => x.includes(',')) ? (
                'list'
            ) : 'string'
        ) : ds.every(x => 0 < x) ? ( // 1970 onwards
            'date'
        ) : 'number'
    )(
        xs.map(x => Date.parse(x))
    );

and we can put that to work in a fold or reduction over our thesaurus in a pattern like:

dictTPTypeGuesses = Object.keys(tagThesaurus)
.reduce(
    (a, k) => Object.assign(a, {
        [k]: typeGuessFromValueStrings(
            tagThesaurus[k]
        )
    }), {}
)

or in full:

Click to expand JS source
(() => {
    'use strict';

    ObjC.import('AppKit');

    const main = () => {

        // Guessing the (Tinderbox) type of TaskPaper tags
        // in the front TaskPaper document.

        const tp3Context = (editor, options) => {
            const tp3Main = () =>
                editor.outline.root.children.map(
                    // Reading text and also named attribute key/values.
                    fmapPureTP(x => {
                        const
                            dct = x.attributes,
                            dateParse = DateTime.parse,
                            unixEpoch = dateParse('1970');
                        return Object.keys(dct).reduce(
                            (a, k) => k.startsWith(
                                'data-'
                            ) && k !== 'data-type' ? (
                                Object.assign(a, {
                                    [k.slice(5)]: (
                                        v => (v && (unixEpoch < v)) ? (
                                            v
                                        ) : dct[k]
                                    )(
                                        0 < dct[k].length ? (
                                            // Is the tag value
                                            // parseable as a date ?
                                            dateParse(dct[k])
                                        ) : ''
                                    )
                                })
                            ) : a, {
                                name: x.bodyContentString
                            }
                        );
                    })
                );

            // ------ GENERICS FOR TASKPAPER CONTEXT ------

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

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

            return tp3Main();
        };


        // ----- MACOS AUTOMATION JS CONTEXT ('JXA') ------
        const
            ds = Application('TaskPaper')
            .documents;
        return either(alert('TaskPaper to Tinderbox'))(
            // JSON copied to the clipboard.
            x => copyText(JSON.stringify(x, null, 2))
        )(
            bindLR(
                ds.length > 0 ? (
                    Right(ds.at(0))
                ) : Left('No TaskPaper documents open')
            )(doc => {
                // ----------- TASKPAPER VALUES -----------
                const
                    forest = doc.evaluate({
                        script: tp3Context.toString(),
                        withOptions: {
                            optionName: 'someValue'
                        }
                    }),
                    tagThesaurus = foldTree(consolidatedThesaurus)(
                        Node({})(forest)
                    ),
                    dictTPTypeGuesses = Object.keys(tagThesaurus)
                    .reduce(
                        (a, k) => Object.assign(a, {
                            [k]: typeGuessFromValueStrings(
                                tagThesaurus[k]
                            )
                        }), {}
                    );


                return Right(dictTPTypeGuesses)
            })
        );
    };

    // -------------------- TASKPAPER ---------------------

    // consolidatedThesaurus :: Dict -> [Dict] -> Dict
    const consolidatedThesaurus = tagDict =>
        subTreeThesauri => subTreeThesauri.reduce(
            // The value of each attribute, appended to a
            // thesaurus of any other values seen for that
            // attribute in the current tree.
            (a, attribs) => Object.assign(
                a,
                Object.keys(attribs).reduce(
                    (b, k) => {
                        const
                            // Value in this instance of the tag.
                            v = attribs[k],
                            // Values seen so far for this tag.
                            values = a[k] || [];
                        return values.includes(v[0]) ? (
                            b // No change. This value has already been seen.
                        ) : Object.assign(b, {
                            // New value for this tag.
                            // Added here to the thesaurus.
                            [k]: values.concat(v)
                        })
                    }, {}
                )
            ),
            // A new object containing each attribute
            // (other than 'name') of the current node,
            // with the value of each attribute,
            // wrapped in a list, so that any other values
            // seen for the same tag can be appended.
            Object.keys(tagDict).reduce(
                (a, k) => 'name' !== k ? (
                    Object.assign(a, {
                        [k]: [tagDict[k]]
                    })
                ) : a, {} // Empty object to serve as accumulator.
            )
        );

    // typeGuessFromValueStrings :: [String] -> String
    const typeGuessFromValueStrings = xs =>
        // A guessed type-name drawn from:
        // {'boolean', 'date', 'number', 'list', 'string'}
        0 === xs.join('').length ? (
            'boolean'
        ) : (
            ds => ds.some(x => isNaN(x)) ? (
                xs.some(x => x.includes(',')) ? (
                    'list'
                ) : 'string'
            ) : ds.every(x => 0 < x) ? ( // 1970 onwards
                'date'
            ) : 'number'
        )(
            xs.map(x => Date.parse(x))
        );

    // ------------------ JXA PRIMITIVES ------------------

    // 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 => {
        // String copied to general pasteboard.
        const pb = $.NSPasteboard.generalPasteboard;
        return (
            pb.clearContents,
            pb.setStringForType(
                $(s),
                $.NSPasteboardTypeString
            ),
            s
        );
    };

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

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

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

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

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

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

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

Thanks so much. This is extremely helpful. I’ll be back tomorrow after I have time to read and study this material.
Best,
Maurice

1 Like

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

Dear Rob,
Thank you! Each email is more instructive than the last. Amazing. You should write a book, and I would be first in line to purchase it.

I do appreciate the idea of doing more in the JXA JS Context as it is coming very close to the way I used orgmode in the past. I love the idea of having something like this plaintext solidity back after leaving the remarkable but fragile world of emacs.

The transformation of trees that you begin with seems through functions seems pretty intuitive-- and only a small step away from the generic JavaScript functions as your toUpper and foldTree (whether of numeric or string values) examples make very plain. So far, so good, and it is fantastic to have these new arrows in my quiver. I can see myself being able to then transform data from Taskpaper and place them elsewhere. And it is nice then to have some portability out of Tinderbox should there come a day for this.

I think I’m following up until your last email when you start discussing the inverse processs of porting the data from TBX to Taskpaper.

However, I sense there is just the step of putting some of these scripts together into a workable solution to create notes on Tinderbox. That is, once we’ve identified the taskpaper key/values and figured out their types, it comes time to send this information onward to TBX. I think this is where your explanation began where you describe how to minimally create one note with given attributes above in JXA. I know you went over some of the details of this above, I’m just a bit puzzled as to how to pull it together. Though it seems like it shouldn’t take too much more work. I’ve been trying to play around with this a bit today (not nearly as much time as I would have liked :)) and can see the outlines of a solution.

So before we move on from the Taskpaper side of things to the porting information back from Tinderbox (which worked like a treat! – but I’ll need to sit down and understand it) how do you deal with the actual creation of the tinderbox notes from the taskpaper file and some of the checking functions you mention once the key/values and guessed their types? Is that OK?

One further question about Taskpaper to TBX that occurred to me was: when you import from Taskpaper do you avoid reduplicating information that you already have in a particular set of TBX notes if you are revising them in Taskpaper? I know the TBX notes have a UUID, so it should be possible to make sure you are updating rather than creating new notes, right?

All the best,
Maurice

Good ! I should be free from c. Sat evening to sketch the basics of creating Tinderbox notes from our light copy of Jesse’s TaskPaper parse tree.

It may be helpful to narrow things down, at first, by choosing the simplest work-flow that seems likely to make sense in your context – there are, of course, various possibilities you can choose from in terms of:

  • Source
    • The path of a TaskPaper file ?
    • The front document in the TaskPaper app ?
      • The whole document ?
      • Only the visible (focused) material ?
      • Selected items ?
        • With their descendants ?
        • Just the items selected ?
  • Channel
    • Script launched by Keyboard Maestro ?
    • ‘Copy As Tinderbox’ followed by paste ?
    • Something else ?
  • Destination
    • New Tinderbox document ?
    • Front document in Tinderbox app ?
      • New notes created at point of GUI selection ?
      • New notes created at top level of Tinderbox outlines ?
      • New notes appended or prepended to some kind of ‘inbox’ destination ?

etc etc

If you would like to sketch out a rough workflow that seems likely to make some sense in your context, then we can grow the specifics around that kernel use case.

Have a good weekend !

Rob

Dear Rob,
Thanks. I hope you had a good weekend too. Thanks for these questions which open up some possibilities I hadn’t considered.

  • Source: I think the selected items with descendants might be a good starting point. It seems the most flexible.

  • Channel: Keyboard Maestro would be my preference as I already use this with TBX.

  • Destination: Again, I think new notes created at point of GUI selection makes the most sense. I think I could then simply replace older notes in TBX with things I edited/created in the Taskpaper file.
    Thanks very much, I think that would fit my various use cases nicely.

All the best,
Maurice

Thanks, that’s very helpful.

Let’s throw a narrow rope across the ravine, and then gradually replace it with something broader, (and a bit more fixed and load-bearing).

Starting with Source above, we have:

  • The selected items,
  • the descendants of those,
  • and the union (without duplicates) of those two sets.

(Union, because an extended selection in the GUI may contain some items that are themselves descendants of other selected items, and we probably don’t want duplication in our export or copy)

The TaskPaper interface anticipates this issue and solves it for us with a built-in function (a specialised ‘method’ of the Selection object):

editor.selection.selectedItemsCommonAncestors

which returns only the top-level lines of a GUI selection, pruning out any lines with ancestors which are already selected.

Let’s obtain a light copy of this subset of the TaskPaper parse tree, capturing only the text and the attribute dictionary of each node in the tree:

editor.selection
.selectedItemsCommonAncestors
.map(
    fmapPureTP(x => ({
        name: x.bodyContentString,
        attribs: x.attributes
    }))
)
Click to reveal full JS
(() => {
    'use strict';

    // Rob Trew 2020

    // First sketch of 'selected items with descendants'
    // (simply returns a JSON tree)

    // ---------------- TASKPAPER CONTEXT -----------------

    const tp3Context = (editor, options) => {
        const tpMain = () =>
            Right(
                editor.selection
                .selectedItemsCommonAncestors
                .map(
                    fmapPureTP(x => ({
                        name: x.bodyContentString,
                        attribs: x.attributes
                    }))
                )
            );

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

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

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

        // ------------
        return tpMain();
    };

    // ------------------- JXA CONTEXT --------------------
    const main = () => {
        const ds = Application('TaskPaper').documents;
        return either(
            alert('Problem')
        )(
            x => JSON.stringify(x, null, 2)
            // OR JUST
            // x => x
        )(
            bindLR(
                ds.length > 0 ? (
                    Right(ds.at(0))
                ) : Left('No TaskPaper documents open')
            )(
                d => d.evaluate({
                    script: tp3Context.toString(),
                    withOptions: {
                        optionName: 'someValue'
                    }
                })
            )
        );
    };

    // 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',
                withIcon: sa.pathToResource('TaskPaper.icns', {
                    inBundle: 'Applications/TaskPaper.app'
                })
            }),
            s
        );
    };

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

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

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

(and then pause to see which parts of the code need a bit more clarification)

The (zipped) KM macro version of this:

Sketch of TP selection with descendants.kmmacros.zip (11.1 KB)

aims to display a JSON format version of the selected TaskPaper items (text and attribute dictionaries), with their descendants.

The next steps will be to:

  • make bare copy of this tree in Tinderbox (text/$Name only, initially),
  • expand the process to bring over TaskPaper tags as Tinderbox attributes, and
  • perhaps add a tree-restructuring phase so that TaskPaper note paragraphs are mapped to the Tinderbox $Text attribute of their parent note, rather than becoming child notes.

Now we throw the rope across – starting to create some Tinderbox notes, as children of a selected existing note, from our Forest of nested Node objects. (Our light copy of the TaskPaper parse tree)

In the JXA version of this (rather than the XML pasting version), there are essentially three phases for each new note:

  • Creation of a new note object, by the Note constructor ‘method’ of the Tinderbox Application object. At this stage, only the name of the note is defined.
  • The new note object is appended to the children of chosen parent object – either another note, or the document itself (for top-level insertions).
  • The new note is decorated with attribute values.

We can simplify by deferring the third of those phases, and focusing on creating and appending named notes.

Embedding those first two phases in a recursive traversal of the whole forest (starting with a GUI selected import parent, and then using each new note as the import parent for any descending subtree that it may have):

// tbxUnadornedOutlineFromTPForest :: TBX Application ->
// TBX (Note | Document) -> [Tree Dict] -> [Tree String]
const tbxUnadornedOutlineFromTPForest = tbxApp =>
    outlineParent => forest => {
        const go = parent => tpDict => {
            const
                // Initialization of a new TBX Note object.
                newNote = tbxApp.Note({
                    name: tpDict.root.name || ''
                });
            return Node(
                (
                    // Effect:
                    // The new note is appended to
                    // the child list of some parent.
                    parent.notes.push(newNote),

                    // Value for .root of returned Node.
                    newNote.name()
                )
            )(
                // Recursion over any descendants,
                // with the new TBX note as an import parent.
                tpDict.nest.map(go(newNote))
            );
        };
        return forest.map(go(outlineParent))
    };
Click for full JS Source
(() => {
    'use strict';

    // Rob Trew 2020

    // First sketch of exporting
    // selected TaskPaper items with descendants
    // as descendants of the selected Tinderbox note.

    // Creates a bare outline at the the insertion point
    // (or top level, in the absence of a GUI selection)
    // in the front Tinderbox document.

    // Ver 0.00

    // ---------------- TASKPAPER CONTEXT -----------------

    const tp3Context = (editor, options) => {
        const tpMain = () =>
            Right(
                editor.selection
                .selectedItemsCommonAncestors
                .map(
                    fmapPureTP(x => ({
                        name: x.bodyContentString,
                        attribs: x.attributes
                    }))
                )
            );

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

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

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

        // ------------
        return tpMain();
    };

    // ------------------- JXA CONTEXT --------------------
    const main = () => {
        const ds = Application('TaskPaper').documents;
        return either(
            alert('Problem')
        )(
            x => JSON.stringify(x, null, 2)
            // OR JUST
            // x => x
        )(
            bindLR(
                ds.length > 0 ? (
                    Right(ds.at(0))
                ) : Left('No TaskPaper documents open')
            )(tpDoc => bindLR(
                tpDoc.evaluate({
                    script: tp3Context.toString(),
                    withOptions: {
                        optionName: 'someValue'
                    }
                })
            )(forest => {
                const
                    tbx = Application('Tinderbox 8'),
                    tbxDocs = tbx.documents;
                return bindLR(
                    0 < tbxDocs.length ? (
                        Right(tbxDocs.at(0))
                    ) : Left('No documents open in Tinderbox 8')
                )(tbxDoc => Right(
                    tbxUnadornedOutlineFromTPForest(tbx)(
                        // Parent object for imported notes.
                        tbxDoc.selectedNote() || tbxDoc
                    )(forest)
                ));
            }))
        );
    };

    // --------------- TINDERBOX FUNCTIONS ----------------

    // tbxUnadornedOutlineFromTPForest :: TBX Application ->
    // TBX (Note | Document) -> [Tree Dict] -> [Tree String]
    const tbxUnadornedOutlineFromTPForest = tbxApp =>
        outlineParent => forest => {
            const go = parent => tpDict => {
                const
                    // Initialization of a new TBX Note object.
                    newNote = tbxApp.Note({
                        name: tpDict.root.name || ''
                    });
                return Node(
                    (
                        // Effect:
                        // The new note is appended to
                        // the child list of some parent.
                        parent.notes.push(newNote),

                        // Value for .root of returned Node.
                        newNote.name()
                    )
                )(
                    // Recursion over any descendants,
                    // with the new TBX note as an import parent.
                    tpDict.nest.map(go(newNote))
                );
            };
            return forest.map(go(outlineParent))
        };


    // ------------------------- 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',
                withIcon: sa.pathToResource('TaskPaper.icns', {
                    inBundle: 'Applications/TaskPaper.app'
                })
            }),
            s
        );
    };

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

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

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

As a (zipped) KM macro (plain export from TP to TBX, without tag values/attributes at this stage)

Plain export (without attributes) to Tinderbox.kmmacros.zip (11.7 KB)

Dear Rob,
Thanks for this! OK, I’m beginning to see what is required to create a group of notes that represent the Taskpaper forest. One minor point is that there can be no spaces between projects in the TP file, or else it produces empty notes in TBX. (Which makes sense of course) It’s not a huge concern at the moment (easy enough to erase these), it’s just a feature of the scripting that one could adjust at some point down the road, I suppose.

Otherwise this works like a charm, and I love the capacity to simply highlight the portion of the Taskpaper file I want to import, that makes things very simple to adjust on the fly.

Speaking of adjusting on the fly, one question occurred to me as I first used this fine piece of code: Would it be possible to hard code in some further appearance attributes for the notes as they are created in map view for instance (i.e. $Height,$Width, $Color)?
That is, can attributes be added into the mix that are not listed in the taskpaper file?

Again, this really isn’t a major concern of mine, so it’s fine to skip over now if it is too much trouble. It’s surely more important to get a firm bridge between the two programs!

Thanks again,
Maurice

Both very tractable – I’ll get back to this on Wednesday evening.

In the meanwhile, to exclude blank lines, you should be able to define a test predicate:

// notEmpty :: TP Node -> Bool
const notEmpty = x =>
    0 < x.bodyContentString.trim().length || (
        x.hasChildren
    );

and use it in filters at two points:

  • The top level of the TP3 JS Context code (tpMain)
const tpMain = () =>
    Right(
        editor.selection
        .selectedItemsCommonAncestors
        .filter(notEmpty) // FILTER ADDED HERE
        .map(
            fmapPureTP1(x => ({
                name: x.bodyContentString,
                attribs: x.attributes
            }))
        )
    );
  • and a modified version of fmapPureTP (let’s call it fmapPureTP1)
// fmapPureTP1 :: (TPItem -> a) -> TPItem -> Tree TPItem
const fmapPureTP1 = f => {
    const go = x => Node(f(x))(
        x.hasChildren ? (
            x.children
            .filter(notEmpty) // FILTER ADDED HERE
            .map(go)
        ) : []
    );
    return go;
};
Click to expand fuller snippet
(() => {
    'use strict';

    // Rob Trew 2020

    // First sketch of 'selected items with descendants'
    // (simply returns a JSON tree)

    // ver 0.2
    // Prunes blank lines from the outline parse tree.

    // ---------------- TASKPAPER CONTEXT -----------------

    const tp3Context = (editor, options) => {
        const tpMain = () =>
            Right(
                editor.selection
                .selectedItemsCommonAncestors
                .filter(notEmpty) // FILTER ADDED HERE
                .map(
                    fmapPureTP1(x => ({
                        name: x.bodyContentString,
                        attribs: x.attributes
                    }))
                )
            );

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

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

        // fmapPureTP1 :: (TPItem -> a) -> TPItem -> Tree TPItem
        const fmapPureTP1 = f => {
            const go = x => Node(f(x))(
                x.hasChildren ? (
                    x.children
                    .filter(notEmpty) // FILTER ADDED HERE
                    .map(go)
                ) : []
            );
            return go;
        };

        // notEmpty :: TP Node -> Bool
        const notEmpty = x =>
            0 < x.bodyContentString.trim().length || (
                x.hasChildren
            );

        // ------------
        return tpMain();
    };

    // ------------------- JXA CONTEXT --------------------
    const main = () => {
        const ds = Application('TaskPaper').documents;
        return either(
            alert('Problem')
        )(
            x => JSON.stringify(x, null, 2)
            // OR JUST
            // x => x
        )(
            bindLR(
                ds.length > 0 ? (
                    Right(ds.at(0))
                ) : Left('No TaskPaper documents open')
            )(
                d => d.evaluate({
                    script: tp3Context.toString(),
                    withOptions: {
                        optionName: 'someValue'
                    }
                })
            )
        );
    };

    // 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',
                withIcon: sa.pathToResource('TaskPaper.icns', {
                    inBundle: 'Applications/TaskPaper.app'
                })
            }),
            s
        );
    };

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

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

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

Though that’s just a temporary fix – we may also need to define the mapping (from TP to TBX) of blank lines which are parents of non-blank lines:

In the example above, the temporary fix would only apply if the empty line was a sibling of the line that followed.

If it’s a parent of following lines, it would be mapped to TBX as their (unnamed) container.

Interim – I sketched out a fuller approach to guessing or specifying attribute types for export from TaskPaper last night. I’ll try to write it up a little and post some code this evening.

(I’ve personally been doing this more manually hitherto – specifying types rather than guessing them, but I think a richer default system, a cleaner way of specifying options, and a few explanatory messages for any infeasible attribute names or types, are probably sensible : -)

Thanks so much for this Rob. I appreciate it very much. I’m curious to see what you will come up with. And yes, that does sound sensible. All best,
Maurice

Here’s the next sketch, which aims to:

  • Export TaskPaper tags to Tinderbox attributes,
  • ignore blank TaskPaper lines (if they have no descendants indented under them),
  • create a draft Tinderbox prototype for the exported notes, specifying the TBX ‘key attributes’ to display. (Could also be used to define an appearance for these notes).

Known limitations (there will be others : -)

  • Creates the export prototype anew each time – doesn’t yet check for an existing copy
  • Doesn’t yet verify that Tinderbox has been able to successfully parse every incoming TaskPaper value (when an Tinderbox attribute attribute is offered a string inconsistent with its type, it just falls back to its default)

Click to expand JS source 0.04
(() => {
    'use strict';

    // Rob Trew 2020

    // First illustrative sketch of exporting
    // *selected* TaskPaper items with descendants
    // as descendants of the selected Tinderbox note.

    // NOT READY FOR PRODUCTION - ILLUSTRATIVE CODE ONLY

    // Creates a bare outline at the the insertion point
    // (or top level, in the absence of a GUI selection)
    // in the front Tinderbox document.

    // Ver 0.04

    // Edited name of a TBX type from 'URL' to 'url'.

    // USER OPTIONS
    // TaskPaper tag names explicitly
    // mapped to Tinderbox Attribute names and type names
    // (Optional – in the absence of entries, the script will
    //  try to guess the destination Tinderbox attribute and type
    // for each TaskPaper tag)
    const userMappings = {
        // range: {
        //     tbxName: 'Range',
        //     tbxType: 'number'
        // }
        // language: {
        //     tbxName: 'Taal',
        //     type: 'boolean'
        // },
        // dur: {
        //     tbxName: 'teatime',
        //     tbxType: 'interval'
        // }
    };

    // ------------------- JXA CONTEXT --------------------
    const main = () => {
        const ds = Application('TaskPaper').documents;
        return either(
            alert('TaskPaper -> Tinderbox')
        )(
            x => JSON.stringify(x, null, 2)
            // OR JUST
            // x => x
        )(
            bindLR(
                ds.length > 0 ? (
                    Right(ds.at(0))
                ) : Left('No TaskPaper documents open')
            )(tpDoc => bindLR(
                tpDoc.evaluate({
                    script: tp3Context.toString(),
                    withOptions: {
                        optionName: 'someValue'
                    }
                })
            )(forest => {
                const
                    tbx = Application('Tinderbox 8'),
                    tbxDocs = tbx.documents;
                return bindLR(
                    0 < tbxDocs.length ? (
                        Right(tbxDocs.at(0))
                    ) : Left('No documents open in Tinderbox 8')
                )(tinderboxNotesFromTaskPaper(tbx)(forest));
            }))
        );
    };

    // ---------------- TASKPAPER CONTEXT -----------------

    const tp3Context = (editor, options) => {
        const tpMain = () =>
            Right(
                editor.selection
                .selectedItemsCommonAncestors
                .filter(notEmpty)
                .map(fmapPureTP1(x => ({
                    name: x.bodyContentString,
                    attribs: x.attributes
                })))
            );


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

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

        // fmapPureTP1 :: (TPItem -> a) -> TPItem -> Tree a
        const fmapPureTP1 = f => {
            // A specialised variant of fmapPureTP
            // which excludes blank lines
            // unless they have indented 'descendants'.
            const go = x => Node(f(x))(
                x.hasChildren ? (
                    x.children.filter(notEmpty).map(go)
                ) : []
            );
            return go;
        };

        // notEmpty :: TP Node -> Bool
        const notEmpty = x =>
            x.hasChildren || Boolean(x.bodyContentString.trim());

        // ------------
        return tpMain();
    };

    // tinderboxNotesFromTaskPaper :: TBX App ->
    // [Tree Dict] -> TBX Doc -> Either String TBX IO ()
    const tinderboxNotesFromTaskPaper = tbx =>
        forest => tbxDoc => bindLR(
            normalizedUserMappingsLR(
                readOnlyAttributes()
            )(userMappings)
        )(dctUserSettings => bindLR(
            defaultRosettaFromThesaurusLR(
                dctUserSettings
            )(prunedThesaurus([
                'indent', 'data-type'
            ])(thesaurusFromTPForest(forest)))
        )(defaultRosetta =>
            bindLR(checkedRosettaLR(tbx)(tbxDoc)(
                defaultRosetta
            ))(compose(
                Right,
                tbxOutlineFromTPForest(tbx)(tbxDoc)(
                    // Parent object for imported notes.
                    tbxDoc.selectedNote() || tbxDoc
                )(forest)
            ))
        ));

    // ----------- TINDERBOX TARGET ATTRIBUTES ------------

    // checkedRosettaLR :: TB Document -> Rosetta ->
    // Either Message Rosetta
    const checkedRosettaLR = tbx =>
        // Either an explanatory message, or a mapping
        // dictionary in which all Tinderbox tag names
        // (data-tag attribute names) are mapped to
        // Tinderbox Attributes which have been found or
        // created, and which have a type that is either
        // guessed from the TaskPaper tag values, or
        // specified in the userMappings dictionary.
        tbxDoc => rosetta => {
            const
                tbDocAttributes = tbxDoc.attributes,
                dctReadOnly = readOnlyAttributes(),
                tags = Object.keys(rosetta),
                problems = tags.flatMap(k => {
                    const
                        dctEntry = rosetta[k],
                        tbxName = dctEntry.tbxName,
                        refAttr = tbDocAttributes.byName(tbxName);
                    return refAttr.exists() ? (
                        dctReadOnly[tbxName] ? (
                            Left(tbxName + ' is read-only')
                        ) : (
                            tbxType => (tbxType !== dctEntry.tbxType) && (
                                tbxType !== 'string'
                            ) ? (
                                Left(
                                    `@${k.slice(5)} -> ${tbxName} :: ` + (
                                        "The TBX type is '" + tbxType + (
                                            `' (not '${dctEntry.tbxType}')`
                                        )
                                    )
                                )
                            ) : []
                        )(refAttr.type())
                    ) : (
                        newAttrib => (
                            tbDocAttributes.push(newAttrib),
                            []
                        )
                    )(tbx.Attribute({
                        name: tbxName,
                        type: isKnownTBAttributeType(
                            dctEntry.tbxType
                        ) ? dctEntry.tbxType : 'string'
                    }));
                });
            return 0 < problems.length ? (
                Left(problems.map(x => x.Left).join('\n\n'))
            ) : Right(rosetta);
        };

    // tbxAttributeFoundOrCreated ::
    //      TBX App -> TBX Attributes ->
    //       String -> String -> TBX Attribute
    const tbxAttributeFoundOrCreated = tbx =>
        // Either a reference to an existing attribute, if found,
        // or to a new attribute of the given name and type,
        // where type is a string drawn from:
        // {boolean,color,date,file,interval,
        //  list,number,set,string,url}
        attribs => strTypeName => attribName => {
            const maybeAttrib = attribs.byName(attribName);
            return maybeAttrib.exists() ? (
                maybeAttrib
            ) : (() => {
                const newAttrib = tbx.Attribute({
                    name: attribName,
                    type: strTypeName
                });
                return (
                    attribs.push(newAttrib),
                    newAttrib
                );
            })();
        };

    // normalizedUserMappingsLR :: Dict -> Dict ->
    // Either String Dict
    const normalizedUserMappingsLR = readOnlys =>
        dctMappings => {
            const
                leftsRights = partitionEithers(
                    Object.keys(dctMappings).map(k => {
                        const
                            attrPrefix = 'data-',
                            attrName = k.startsWith(attrPrefix) ? (
                                k
                            ) : attrPrefix + k,
                            v = dctMappings[k];
                        return 'string' !== typeof v ? (() => {
                            const
                                ps = Object.getOwnPropertyNames(v),
                                [mbNameKey, mbTypeKey] = [
                                    x => x.endsWith('ame'),
                                    x => x.endsWith('ype'),
                                ].map(flip(find)(ps)),
                                tbName = mbNameKey.Nothing ? (
                                    k
                                ) : v[mbNameKey.Just].trim() || k,
                                tbType = mbTypeKey.Nothing ? (
                                    undefined
                                ) : v[mbTypeKey.Just].trim();
                            return readOnlys[tbName] ? (
                                Left(`@${k} -> ${tbName} :: '${tbName}'` + (
                                    ' is a read-only Tinderbox attribute.'
                                ))
                            ) : isKnownTBAttributeType(tbType) || (
                                !Boolean(tbType)
                            ) ? (
                                Right(TupleN(attrName, tbName, tbType))
                            ) : Left(`@${k} -> ${tbName} :: '${tbType}'` + (
                                ' is not a known TBX Attribute type.\n\n' + (
                                    'The known types are (a-z):\n' + (
                                        bulleted(knownTBAttributeTypeNames())
                                    )
                                )
                            ))
                        })() : readOnlys[v] ? (
                            Left(`@${k} -> ${v} :: ${v} is ` + (
                                ' is a read-only Tinderbox attribute.'
                            ))
                        ) : Right(TupleN(attrName, v, 'string'))
                    })
                );
            return 0 < leftsRights[0].length ? (
                Left(unlines(leftsRights[0]))
            ) : Right(leftsRights[1].reduce(
                (a, triple) => Object.assign(a, {
                    [triple[0]]: {
                        'tbxName': triple[1],
                        'tbxType': triple[2]
                    }
                }), {}
            ))
        };


    // defaultRosettaFromThesaurus :: Thesaurus -> Either String Rosetta
    const defaultRosettaFromThesaurusLR = userMappings =>
        //
        thesaurus => Right(Object.keys(thesaurus).reduce(
            (a, k) => {
                const maybeSetting = userMappings[k];
                return Object.assign(a, {
                    [k]: {
                        tbxName: tbxAttrName(userMappings)(k),
                        tbxType: Boolean(maybeSetting) && (
                            Boolean(maybeSetting.tbxType)
                        ) ? (
                            maybeSetting.tbxType
                        ) : tbTypeGuess(thesaurus[k] || [])
                    }
                })
            }, {}
        ));


    // tpAttrName :: String -> String
    const tpAttrName = k =>
        'data-' + k[0].toLocaleLowerCase() + k.slice(1);

    // tbxAttrName :: Dict -> String -> String
    const tbxAttrName = dctMappings =>
        k => {
            // Where k is assumed to have the prefix 'data-'
            const maybeMapping = dctMappings[k];
            return (
                maybeMapping ? (
                    maybeMapping.tbxName // Could still be undefined.
                ) : undefined
            ) || firstUpper(k.slice(5));
        };

    // firstUpper :: String -> String
    const firstUpper = k =>
        k[0].toLocaleUpperCase() + k.slice(1)

    // tbTypeGuess :: String -> String
    const tbTypeGuess = xs => {
        const
            rgxDate = /^[0-9]+\-[0-9]+/,
            rgxInterval = /^\d\d:\d\d$/;
        return xs.every(x => 0 === x.length) ? (
            'boolean'
        ) : xs.every(x => !isNaN(x)) ? (
            'number'
        ) : xs.every(x => rgxDate.test(x)) ? (
            'date'
        ) : xs.every(x => rgxInterval.test(x)) ? (
            'interval'
        ) : xs.some(x => x.includes(',')) ? (
            'list'
        ) : xs.some(x => x.includes(';')) ? (
            'set'
        ) : 'string';
    };

    // knownTBAttributeTypeNames :: () -> [String]
    const knownTBAttributeTypeNames = () => [
        'boolean',
        'color',
        'date',
        'file',
        'interval',
        'list',
        'number',
        'set',
        'string',
        'url'
    ];

    // tbAttributeTypeNameListing :: () -> String
    const tbAttributeTypeNameListing = () =>
        knownTBAttributeTypeNames();

    // isKnownTBAttributeType :: String -> Bool
    const isKnownTBAttributeType = k =>
        // True if k is a known Tinderbox type name;
        knownTBAttributeTypeNames().includes(k);

    // ------------- TASKPAPER TAG THESAURUS --------------

    // thesaurusFromTPForest :: Forest Dict -> Thesaurus
    const thesaurusFromTPForest = forest =>
        concatThesaurus(
            map(foldTree(
                x => xs => mappendThesaurus(
                    pureThesaurus(x.attribs)
                )(concatThesaurus(xs))
            ))(forest)
        );

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

    // concatThesaurus :: [Thesaurus] -> Thesaurus
    const concatThesaurus = xs =>
        // A chain of Thesauri concatenated into one.
        0 < xs.length ? (
            foldl1(mappendThesaurus)(xs)
        ) : {};

    // prunedThesaurus :: String -> Thesaurus -> Thesaurus
    const prunedThesaurus = excludedKeys =>
        // A partial copy of a Thesaurus, without
        // entries for a list of excluded keys.
        thesaurus => Object.keys(thesaurus).flatMap(
            k => excludedKeys.includes(k) ? (
                []
            ) : [k]
        ).reduce((a, k) => (a[k] = thesaurus[k], a), {});

    // mappendThesaurus :: Thesaurus -> Thesaurus -> Thesaurus
    const mappendThesaurus = x => y =>
        // Two thesauri combined into one.
        nub(Object.keys(x).concat(
            Object.keys(y)
        )).reduce(
            (a, k) => Object.assign(a, {
                [k]: nub(
                    (x[k] || [])
                    .concat(y[k] || [])
                )
            }), {}
        );


    // pureThesaurus :: Dict -> Thesaurus
    const pureThesaurus = dct =>
        // A dictionary lifted to a Thesaurus,
        // in which all values are lifted into lists.
        Object.keys(dct).reduce(
            (a, k) => Object.assign(a, {
                [k]: [dct[k]]
            }), {}
        );


    // --------------- TINDERBOX FUNCTIONS ----------------

    const readOnlyAttributes = () => ({
        'AdornmentCount': true,
        'Associates': true,
        'ChildCount': true,
        'Created': true,
        'Creator': true,
        'DescendantCount': true,
        'DisplayName': true,
        'HTMLExportPath': true,
        'ID': true,
        'ImageCount': true,
        'InboundLinkCount': true,
        'IsAdornment': true,
        'IsAlias': true,
        'IsComposite': true,
        'LastFetched': true,
        'Modified': true,
        'NoteURL': true,
        'OutboundLinkCount': true,
        'OutlineDepth': true,
        'OutlineOrder': true,
        'Path': true,
        'Places': true, // reserved
        'PlainLinkCount': true,
        'ReadCount': true,
        'SelectionCount': true,
        'SiblingOrder': true,
        'TextLength': true,
        'TextLinkCount': true,
        'WebLinkCount': true,
        'WordCount': true
    });

    // tbxOutlineFromTPForest :: TBX Application ->
    // TBX (Note | Document) -> Dict -> [Tree Dict] -> [Tree String]
    const tbxOutlineFromTPForest = tbxApp =>
        tbxDoc => outlineParent => forest => rosetta => {
            const
                ks = Object.keys(rosetta).map(
                    k => rosetta[k].tbxName
                ),
                importProtoType = (() => {
                    const
                        prototype = tbxApp.Note({
                            name: 'importPrototype'
                        });
                    tbxDoc.notes.push(prototype);

                    const protoAttribs = prototype.attributes;
                    return (
                        protoAttribs.byName(
                            'KeyAttributes'
                        ).value = ks.join(';'),
                        protoAttribs.byName('IsPrototype').value = 'true',
                        prototype
                    );
                })();
            const go = parent => tpDict => {
                const
                    // Initialization of a new TBX Note object.
                    nodeVal = tpDict.root,
                    newNote = tbxApp.Note({
                        name: nodeVal.name || ''
                    });
                return Node(
                    (
                        // Effect:
                        // The new note is appended to
                        // the child list of some parent.
                        parent.notes.push(newNote),
                        (() => {
                            const
                                attrs = newNote.attributes,
                                tags = nodeVal.attribs;

                            attrs.byName('Prototype')
                                .value = 'importPrototype';
                            Object.keys(tags).forEach(k => {
                                if (!['indent', 'data-type'].includes(k)) {
                                    const
                                        entry = rosetta[k],
                                        tbxName = entry.tbxName,
                                        tbxType = entry.tbxType;
                                    if (
                                        attrs.byName(tbxName).exists()
                                    ) {
                                        attrs.byName(tbxName).value = (
                                            'boolean' === tbxType ? (
                                                'true'
                                            ) : ['list', 'set']
                                            .includes(tbxType) ? (
                                                asTbxList(tags[k])
                                            ) : tags[k]
                                        );
                                    }
                                }
                            })
                        })(),
                        // Value for .root of returned Node.
                        newNote.name()
                    )
                )(
                    // Recursion over any descendants,
                    // with the new TBX note as an import parent.
                    tpDict.nest.map(go(newNote))
                );
            };
            return map(go(outlineParent))(forest);
        };

    // asTbxList :: String -> String
    const asTbxList = s =>
        // Tinderbox list string.
        s.split(',')
        .map(k => k.trim())
        .join(';');


    // ------------------------- 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',
                withIcon: sa.pathToResource('TaskPaper.icns', {
                    inBundle: 'Applications/TaskPaper.app'
                })
            }),
            s
        );
    };

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


    // Just :: a -> Maybe a
    const Just = x => ({
        type: 'Maybe',
        Nothing: false,
        Just: x
    });

    // Nothing :: Maybe a
    const Nothing = () => ({
        type: 'Maybe',
        Nothing: true,
    });

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

    // TupleN :: a -> b ...  -> (a, b ... )
    function TupleN() {
        const
            args = Array.from(arguments),
            n = args.length;
        return 2 < n ? Object.assign(
            args.reduce((a, x, i) => Object.assign(a, {
                [i]: x
            }), {
                type: 'Tuple' + n.toString(),
                length: n
            })
        ) : args.reduce((f, x) => f(x), Tuple);
    };

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

    // bulleted :: [String] -> String -> String
    const bulleted = xs =>
        xs.map(
            x => '' !== x ? '\t- ' + x : x
        ).join('\n');

    // 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 => (
        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;

    // eq (==) :: String -> String -> Bool
    const eq = a =>
        // True when a and b are equivalent in the terms
        // defined below for their shared data type.
        b => a === b

    // find :: (a -> Bool) -> [a] -> Maybe a
    const find = p =>
        // Just the first element in xs which
        // matches the predicate p, or
        // Nothing if no match is found.
        xs => xs.constructor.constructor.name !== (
            'GeneratorFunction'
        ) ? (() => {
            const
                ys = list(xs),
                i = ys.findIndex(p);
            return -1 !== i ? (
                Just(ys[i])
            ) : Nothing();
        })() : findGen(p)(xs);

    // flip :: (a -> b -> c) -> b -> a -> c
    const flip = op =>
        // The binary function op with its arguments reversed.
        1 < op.length ? (
            (a, b) => op(b, a)
        ) : (x => y => op(y)(x));


    // foldl1 :: (a -> a -> a) -> [a] -> a
    const foldl1 = f =>
        // Left to right reduction of the non-empty list xs,
        // using the binary operator f, with the head of xs
        // as the initial acccumulator value.
        xs => (
            ys => 1 < ys.length ? (
                ys.slice(1).reduce(uncurry(f), ys[0])
            ) : ys[0]
        )(list(xs));

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

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


    // initialCap :: String -> String
    const initialCap = s =>
        s[0].toLocaleUpperCase() + s.slice(1);


    // 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 => list(xs).map(f);

    // nub :: [a] -> [a]
    const nub = xs =>
        nubBy(eq)(xs);

    // nubBy :: (a -> a -> Bool) -> [a] -> [a]
    const nubBy = fEq => {
        const go = xs => 0 < xs.length ? (() => {
            const x = xs[0];
            return [x].concat(
                go(xs.slice(1)
                    .filter(y => !fEq(x)(y))
                )
            )
        })() : [];
        return compose(go, list);
    };

    // partitionEithers :: [Either a b] -> ([a],[b])
    const partitionEithers = xs =>
        xs.reduce(
            (a, x) => undefined !== x.Left ? (
                Tuple(a[0].concat(x.Left))(a[1])
            ) : Tuple(a[0])(a[1].concat(x.Right)),
            Tuple([])([])
        );

    // reverse :: [a] -> [a]
    const reverse = xs =>
        'string' !== typeof xs ? (
            xs.slice(0).reverse()
        ) : xs.split('').reverse().join('');


    // reverseDict :: Dict -> Dict
    const reverseDict = dct =>
        // A new dictionary with the
        // keys and values swapped.
        Object.fromEntries(
            Object.entries(dct).map(reverse)
        );

    // root :: Tree a -> a
    const root = tree => tree.root;

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

    // snd :: (a, b) -> b
    const snd = tpl => tpl[1];

    // uncurry :: (a -> b -> c) -> ((a, b) -> c)
    const uncurry = f =>
        // A function over a pair, derived
        // from a curried function.
        function () {
            const
                args = arguments,
                xy = Boolean(args.length % 2) ? (
                    args[0]
                ) : args;
            return f(xy[0])(xy[1]);
        };

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

The interim draft above (0.03) is built around the central indeterminacy of TaskPaper to Tinderbox export, which is that:

  • TaskPaper tag values are lightly typed, flexible, and fault-tolerant,
  • whereas Tinderbox attribute values are strongly typed, and discard any strings which can’t be parsed in terms of the specified data type.

For example, if a given TBX attribute has the type 'date' , and we offer it a string which it can’t successfully parse as a date, the attribute value just defaults to the special string 'never' , and the string we wanted to import is rejected.

In my own exports I was handling this with explicit types, hand-written into the scripts.

In this draft, I’ve tried to make it a little more general, while still allowing for user-specified mappings of particular TaskPaper tags to explicitly named (and explicitly typed) Tinderbox Attributes.

Near the top of the code you can add details to a JS dictionary of the pattern:

const userMappings = {
    tagName : {
        tbxName :AttributeName,
        tbxType: typeName
   },
   tagName2 : {
        tbxName :AttributeName2,
        tbxType: typeName2
   }
}

where

  • userMappings needs to be parseable as a well-formed JS object,
  • typeName needs to be a string drawn from the set {'boolean', 'color', 'date', 'file', 'interval', 'list', 'number', 'set', 'string', 'URL'}
  • and we need to be confident that the values of the named TaskPaper tag will indeed be successfully interpreted by Tinderbox in terms of the specified type.

(if you want to play very safe, you can just export all TaskPaper tag values to Tinderbox attributes of the type 'string' )

What happens if you don’t offer custom user mappings as above ?

The broad picture is that the script attempts to use sensible defaults, and builds a kind of Rosetta-stone dictionary of:

  • TaskPaper tag/attribute names,
  • Corresponding Tinderbox attribute names and types

It also aims to halt and offer error messages if:

  • The target attribute name (user specified, or derived from the TP tag name) already corresponds to an existing read-only TBX attribute
  • The target data type (user specified, or guessed from the range of values seen in the TaskPaper tag) doesn’t match the data type of an existing TBX attribute with the target name.