Importing TaskPaper outlines into Omniplan

Here is a first (macOS) sketch of the absolute basics of importing a TaskPaper outline file into OmniPlan.

Just some quick testing this evening with a trial of OmniPlan4, and all it does so far is:

  • Prompt for selection of a file
  • import any selected TaskPaper file into the top level of the active OmniPlan project as a task outline.

This first draft only imports task names - for the moment it ignores:

  • TaskPaper tags,
  • and the TaskPaper distinctions between trailing comma ‘projects’, bulleted ‘tasks’, and unadorned ‘notes’

If you think you might use something like this (which could in principle, once fleshed out a bit, be converted into a plugin for macOS and iOS versions of OmniPlan, let me know what mappings of TaskPaper tags and item types to OmniPlan data elements might seem sensible in your context.

This first sketch is written as omniJS code to be called from JXA for Automation, so you should be able to run it from Script Editor, Keyboard Maestro etc.

(Note that the imported material comes in fully collapsed, needing View > Task Outline > Expand All
as far as I can see task expansion is not yet exposed in the OmniPlan API)

Draft JS
(() => {
    'use strict';

    // Rob Trew 2020
    // First rough sketch of TaskPaper import to OmniPlan(4)

    // Ver 0.01

    // This (mac) version only imports task titles, 
    // in a fully collapsed state, and doesn't yet 
    // do anything with:
    // - TaskPaper tags
    // - The distinctions between the 3 TaskPaper item types.

    // OmniJS code to be run from a JXA context,
    // such as Script Editor, Keyboard Maestro etc.

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

    // jxaMain :: IO ()
    const jxaMain = () => {
        const op = Application('com.omnigroup.OmniPlan4');
        return (
            op.activate(),
            op.evaluateJavascript(
                `(${opContext.toString()})()`
            )
        );
    };

    // ---------------- OMNIPLAN JS CONTEXT ----------------

    // opContext :: () -> String
    const opContext = () => {
        const opMain = () => {
            const picker = new FilePicker();
            picker.show().then(urls =>
                0 < urls.length && urls[0]
                .fetch(data => {
                    const txt = data.toString().trim();
                    return bindLR(
                        0 < txt.length ? Right(
                            forestFromTaskPaperString(
                                txt
                            )
                        ) : Left('No TaskPaper content.')
                    )(forest => {
                        const
                            tree = transcribedForest(
                                actual.rootTask
                            )(forest);
                        console.log(
                            `Imported ${
                                foldTree(
                                    _ => xs => 1 + sum(xs)
                                )(tree) - 1
                            } TaskPaper items.`
                        )
                    })
                })
            );
            return 'Choose file for import from TaskPaper';
        };

        // ----------------- OMNIPLAN ------------------

        const transcribedForest = parent =>
            xs => {
                const go = p => nodes =>
                    nodes.map(
                        node => {
                            const task = p.addSubtask();
                            return (
                                task.title = node.root.text,
                                Node(task)(
                                    go(task)(node.nest)
                                )
                            );
                        }
                    );
                return Node({})(
                    go(parent)(xs)
                );
            }

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

        // forestFromLineIndents :: [(Int, String)] -> [Tree String]
        const forestFromLineIndents = tuples => {
            const go = xs =>
                0 < xs.length ? (() => {
                    // Lines indented under this first line,
                    // tupled with all the rest.
                    const [n, s] = Array.from(xs[0]);
                    const [firstTreeLines, rest] = Array.from(
                        span(x => n < x[0])(xs.slice(1))
                    );

                    // This first tree, and then the rest.
                    return [
                        Node({
                            body: s,
                            depth: n
                        })(go(firstTreeLines))
                    ].concat(go(rest));
                })() : [];
            return go(tuples);
        };

        // forestFromTaskPaperString :: String -> Tree Dict
        const forestFromTaskPaperString = s => {
            const
                tpItemType = x => x.startsWith('- ') ? ({
                    text: x.slice(2),
                    type: 'task'
                }) : x.endsWith(':') ? ({
                    text: x.slice(0, -1),
                    type: 'project'
                }) : {
                    text: x,
                    type: 'note'
                },
                tpTagDict = xs => xs.reduce((a, x) => {
                    const kv = x.split('(');
                    return Object.assign(a, {
                        [kv[0]]: 1 < kv.length ? (
                            kv[1].split(')')[0]
                        ) : ''
                    })
                }, {}),
                tpParse = dct => {
                    const
                        pair = bimap(tpItemType)(tpTagDict)(
                            uncons(
                                splitOn(' @')(dct.body)
                            ).Just
                        );
                    return Object.assign({}, dct, pair[0], {
                        tags: pair[1]
                    });
                };
            return compose(
                map(fmapTree(tpParse)),
                forestFromLineIndents,
                indentLevelsFromLines,
                filter(Boolean),
                lines
            )(s);
        };

        // indentLevelsFromLines :: [String] -> [(Int, String)]
        const indentLevelsFromLines = xs => {
            const
                indentTextPairs = xs.map(compose(
                    first(length),
                    span(isSpace)
                )),
                indentUnit = minimum(
                    indentTextPairs.flatMap(pair => {
                        const w = fst(pair);
                        return 0 < w ? [w] : [];
                    })
                );
            return indentTextPairs.map(
                first(flip(div)(indentUnit))
            );
        };

        // ------------------ GENERIC ------------------

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


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


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


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


        // bimap :: (a -> b) -> (c -> d) -> (a, c) -> (b, d)
        const bimap = f =>
            // Tuple instance of bimap.
            // A tuple of the application of f and g to the
            // first and second values respectively.
            g => tpl => 2 !== tpl.length ? (
                bimapN(f)(g)(tpl)
            ) : Tuple(f(tpl[0]))(
                g(tpl[1])
            );


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

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


        // div :: Int -> Int -> Int
        const div = x =>
            y => Math.floor(x / y);


        // filter :: (a -> Bool) -> [a] -> [a]
        const filter = p =>
            // The elements of xs which match
            // the predicate p.
            xs => [...xs].filter(p);


        // first :: (a -> b) -> ((a, c) -> (b, c))
        const first = f =>
            // A simple function lifted to one which applies
            // to a tuple, transforming only its first item.
            xy => Tuple(f(xy[0]))(
                xy[1]
            );


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


        // fmapTree :: (a -> b) -> Tree a -> Tree b
        const fmapTree = f => {
            // A new tree. The result of a structure-preserving
            // application of f to each root in the existing tree.
            const go = tree => Node(f(tree.root))(
                tree.nest.map(go)
            );
            return go;
        };


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


        // isSpace :: Char -> Bool
        const isSpace = c =>
            // True if c is a white space character.
            /\s/.test(c);


        // length :: [a] -> Int
        const length = xs =>
            // Returns Infinity over objects without finite
            // length. This enables zip and zipWith to choose
            // the shorter argument when one is non-finite,
            // like cycle, repeat etc
            'GeneratorFunction' !== xs.constructor.constructor.name ? (
                xs.length
            ) : Infinity;


        // lines :: String -> [String]
        const lines = s =>
            // A list of strings derived from a single
            // newline-delimited string.
            0 < s.length ? (
                s.split(/[\r\n]/)
            ) : [];


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


        // minimum :: Ord a => [a] -> a
        const minimum = xs => (
            // The least value of xs.
            ys => 0 < ys.length ? (
                ys.slice(1)
                .reduce((a, y) => y < a ? y : a, ys[0])
            ) : undefined
        )(list(xs));


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


        // span :: (a -> Bool) -> [a] -> ([a], [a])
        const span = p =>
            // Longest prefix of xs consisting of elements which
            // all satisfy p, tupled with the remainder of xs.
            xs => {
                const
                    ys = 'string' !== typeof xs ? (
                        list(xs)
                    ) : xs,
                    iLast = ys.length - 1;
                return splitAt(
                    until(
                        i => iLast < i || !p(ys[i])
                    )(i => 1 + i)(0)
                )(ys);
            };


        // splitAt :: Int -> [a] -> ([a], [a])
        const splitAt = n =>
            xs => Tuple(xs.slice(0, n))(
                xs.slice(n)
            );


        // splitOn :: String -> String -> [String]
        const splitOn = pat => src =>
            /* A list of the strings delimited by
               instances of a given pattern in s. */
            src.split(pat)


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


        // uncons :: [a] -> Maybe (a, [a])
        const uncons = xs => {
            // Just a tuple of the head of xs and its tail, 
            // Or Nothing if xs is an empty list.
            const lng = length(xs);
            return (0 < lng) ? (
                Infinity > lng ? (
                    Just(Tuple(xs[0])(xs.slice(1))) // Finite list
                ) : (() => {
                    const nxt = take(1)(xs);
                    return 0 < nxt.length ? (
                        Just(Tuple(nxt[0])(xs))
                    ) : Nothing();
                })() // Lazy generator
            ) : Nothing();
        };

        // until :: (a -> Bool) -> (a -> a) -> a -> a
        const until = p => f => x => {
            let v = x;
            while (!p(v)) v = f(v);
            return v;
        };

        // ---
        return opMain();
    };

    return jxaMain()
})();

Just for the sake of discussion, here are some possible tags:

Tag Task Property
@duration(2d) duration
@effort(2d) effort
@estimate(3d,5d,6d) minEffortEstimate, expectedEffortEstimate, maxEffortEstimate
@complete(50) effortDone or effortRemaining (based on effort)
@due(2020-12-31) endNoLaterThanDate
@until(2020-11-30) endNoEarlierThanDate
@end(2020-12-31) lockedEndDate
@start(2020-1-31) lockedStartDate
@earliest(2020-2-29) startNoEarlierThanDate
@latest(2020-3-31) startNoLaterThanDate
@cost(200) staticCost
@milestone type
@staff(Ben,Hoss,Adam,"Little Joe") assignments
@material(rope,280) assignments
@equipment(horse,4) assignments
@anytag and @depends(anytag) prerequisites

It would be nice to better define resources and calendars, but that might be outside the scope of TaskPaper.

1 Like

I’ll take a look at these – perhaps at the weekend.

One detail that I’m not sure about here – prerequisites and assignments are read-only properties of Task in the API, so perhaps out of reach for import ?

Though perhaps the .addAssignment() and .addPrerequisite() methods provide viable routes, on closer inspection.

Tag Task
@complete(50) effortDone or effortRemaining (based on effort)

Could I ask you to unpack the envisaged use of this one a little ?

(and the envisaged mechanism of choice between those two target properties – 50 represents a percentage here ?)


The other pair of names here that I’m not completely clear about are:

Tag Task
@end(date) lockedEndDate
@start(date) lockedEndDate

In the OP build I’m looking at (4.0.2 (v205.7.0)), the (presumably) corresponding property names seem to be manualEndDate and manualStartDate (locked doesn’t seem to figure in the API doc)

Is that a build difference ? If it is, then perhaps the import/export code needs to branch on a version number …


Ah, got it, I think. The 4.01 release notes tell us:

Good question. I did intend complete to be a percentage, and I should have just referenced effortDone.
I have been referencing the OP 3 version of the API. I’m using iOS, and OP 4 hasn’t been released yet (although I think there’s a TestFlight beta). I suppose that, even when OP4 is available, not everyone will upgrade, so being version aware would be nice.

1 Like

I could have been clearer on that. I was intending addAssignment() (which, of course, also requires creating unique resources) and addPrerequisite() (which probably requires a handle to the task associated with the tag).

1 Like

That’s very helpful, and a good set of sensible tag names – thanks.

Given the context of iOS, can I ask you in what app(s) you would normally prepare your TaskPaper format file ?

(on macOS I’ve made a KM menu of those names, with predictive typing for selection – just wondering what options there are for a similar device to remind the user of available (importable) tags from the iOS end)

The apps I’m aware that support TaskPaper and provide some support for tags.are Editorial and Taskmator. (Drafts is another app that understands TaskPaper enough to correctly format files, but there doesn’t appear to be any real tag support).

Editorial, which I prefer, has a snippets feature that could potentially work. The main limitation is that there is no built-in way to import snippets, so the user would likely need to define their own (although I guess you could theoretically set up the snippets with a custom workflow).

Taskmator lets you configure default tags (and keeps track of the tags you’ve already used).

1 Like

Thanks – that’s helpful.

Once the macOS draft is importing the core tags, I may rely on you to do some of the iOS testing (I don’t have an iOS copy of OP here to check things on)

I’m happy to help with the iOS testing.

I’ll aim to have a first rough draft of something by Sunday night.

PS what use case entails least friction on iOS ?

On macOS the default might be to put up a file-picker, and browse to a TaskPaper file for import to OmniPlan,

but given the iOS context would it make more sense to read the clipboard for TaskPaper content ?


Though given the slight platform lag between APIs, I wonder if that is an option, yet, on the iOS side ?

The macOS release notes suggest that clipboard operations were implemented in a May release:

I wonder what the Pasteboard entry (if any) looks like in the iOS :: OmniPlan > Automation > API Reference notes ?

The FilePicker does work on iOS. Not completely sure about filetypes, though, although I’m assuming TaskPaper is basically plaintext.

Note: Omni’s “Conference” example has a fairly significant bug (pickerPromise is defined by the alert's show callback, so it doesn’t exist when pickerPromise.then is referenced).

I did not find a Pasteboard entry in the iOs API documentation.

Thanks, that’s helpful. I’ll start with the FilePicker.

(As you say, TaskPaper is just plain text).

Let’s see how far we can get. My impression is that the API hasn’t yet seen enough use for a number of fairly basic bugs to be noticed and cleared up.

On the OP -> TaskPaper export side, Duration values, for example, appear to be illegible to the API, though they are writable for import.

OK, a sanity check as a prelude to engaging with the tags – does this enable you to import your TaskPaper files as OmniPlan outlines on iOS ?

(Tags are still ignored in this draft, which assumes the tab-indented structure expected by the canonical TaskPaper 3 parser – i.e. any child is expected to be indented one tab deeper than its parent)

TaskPaper-Import.omnijs.zip (6.2 KB)

Yes, the import works correctly on iOS, in terms of operation of the FilePicker and creating OmniPlan outlines.

1 Like

And for round-tripping (assuming a paired OP -> TaskPaper writer), do you happen to know if FileSaver seems to be working on the current iOS build of OmniPlan ?

Yes, it is basically working. There seems to be a bit of a logic bug in terms of replacing existing files: if you say that you want to replace the old file, it appears to delete it, then error out complaining that it doesn’t exist, without writing the new file. However, that means it works every second time.

1 Like

(Planning to put in some work on this over the weekend).

One question re the @material and @equipment tags (and assignments generally):

Do you see any need, in your own applications, for multiple assignments for a single task (some donkeys as well as horses, hooks as well as ropes, wine as well as coffee etc etc) ?

if so, any thoughts or preferences on how a list of (name, quantity) tuples might best be encoded in a TaskPaper format ?


Context:

The canonical TaskPaper 3 parser convention is that if we write, on one line:

- haul fleet @material(rope,280) @material(hook,100) then the latter instance of @material simply overwrites and displaces the former.

(The model is that the tag attributes of an item represent a dictionary, in which a given key – tag name – can have only one binding)


Perhaps:

@material(rope, 280, hook, 100)

or:

@material(rope 280, hook 100)

?


I notice that this kind of thing also parses, apparently without a problem – using a semicolon to separate additional (resource, quantity) pairs:

@material(horses,20; carriages,5)