TaskPaper ⇄ Tinderbox import export

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.

As for the finer-grained picture, I’m not sure which, if any, of the details interest you, but the stages are:

  • A thesaurus of all the tag names found in the TaskPaper source, each with a list of all the values they contain, and a guess of their type, in terms of the main type names available in Tinderbox,
  • a check that any userMappings dictionary settings are well-formed,
  • a default rosetta dictionary defined by any userMappings settings, with gaps filled in by reference to the thesaurus,
  • and a final check of the default rosetta dictionary against the target Tinderbox document, creating any missing attributes in that document, and reporting any type clashes.

For more confidence and clarity, we could add a further check, after each value is written to an attribute, that the value has been successfully parsed, and hasn’t been discarded in favour of a default. (logging any losses to some stream or dialog)

( As Apple-Event interfaces are a little slow, that would add some seconds to each script run, but it might be worthwhile )

Another possible phase (which I think I mentioned before), is a structural rewrite which absorbs some subset of TaskPaper leaf notes as Tinderbox $Text attributes of their parent items (tasks or projects).

(I’m not sure whether that matches your workflow)

Dear Rob,
Thank you! This is extremely helpful and a very flexible solution. I’m likely to be specifying TaskPaper tags to explicitly typed Tinderbox Attributes.

I’ll get back to you soon with more. Your last point, about absorbing the leaves as $Text attributes, is really interesting too. I’ll need to think about the consequences. Off the top of my head, I think it would actually match my workflow, because the leaves often consist of the most particular information that doesn’t lend itself to classing according to TBX attributes.

All the best,
Maurice

Dear Maurice,

This conversation is awesome, but I feel as if someone comes into it in the middle of it, he will get quickly lost. Would it be possible to condensed all of this into a new post that would be helpful to a future user like you that is looking forward to integrate Hook, Tinderbox, and TaskPaper? That will be an incredible contribution to this community and one of the things that makes TaskPaper such a good program. That it has such an amazing community that puts so much of their time to make this a better program.

1 Like

I’ve taken the liberty of moving the TaskPaper ⇄ Tinderbox import export component to its own thread here.

Once the import and export scripts have settled down, I’ll put them in the Extensions Wiki here, and (with some notes on options and implementation) on Github.

1 Like

Dear Victor, Dear Rob,
Thanks to you both for these excellent suggestions and ideas.
It’s fascinating to see how Rob manages to refine these scripts and I am sure
others will benefit too. Thanks again.

All the best,
Maurice

Dear Rob,
Apologies for my delay. I’ve been busy with publication deadlines.

This script works very well. One hiccup that I just sorted is that for some reason the Tinderbox type of URL should actually be all lowercase? One would have no way of knowing this from the documentation see e.g. ReferenceURL where the type is listed as URL not url. Once I adjusted for this in the script like so:

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

I could then specify the type ‘url’ in the user defined attributes, and it worked well. Without your excellent error reporting built in, I never would have seen this problem.

With this matter solved, I’m going to work on figuring out how I’m going to send some of the @todo @duedate taskpaper tags to Omnifocus. I think that should be more straightforward than this.

I’ll keep tinkering with this script though to see if I can find any other of these strange cases which pop up.

Thanks again.

All the best,
Maurice

Good catch ! Thank you for all the testing work …