Importing TaskPaper outlines into Omniplan

PS parsing can be flexible, to some extent.

FWIW I notice that on this side, for the moment, I’ve:

  • dropped the semi-colons (a little noisy)
  • used commas (in the way you suggest), to couple names with quantities
  • simply used spaces to delimit multiple assignments of the same kind

so provisionally (optionally omitting the quantity if it’s 1, 1.0, 100%), things like:

@material(horses,20 carriages,5)
// also parsing
@material(horses,20; carriages,5)

@person(Ravna Pham,1 Jefri,50% Johanna)

@group(Engineering,2%)

@material(Coffee,6 Wine,2)
// also parsing an optional or alternative semicolon
@material(Coffee,6; Wine,2)
@material(Coffee,6;Wine,2)

@equipment(Lab)  // Implies 1.0 or 100%
@equipment(Lab,50%)
// also parsed:
@equipment(Lab,0.5)
@equipment(Lab, 0.5)

but let me know if you prefer some alternative, like

@material(coffee=6, wine=2)

@material(coffee:6, wine:2) (ah … this embedded colon turns out to clash with url parsing)

etc

In preparation for testing imports, a sample TaskPaper test file generated (using the JXA script below for macOS) from an old OmniGroup sample project.

[ We can’t yet export with omniJS unfortunately – it can’t yet read durations, and misses some properties need for handling GUI selections – in particular parent, where the JXA API has .taskParent() ]

(Not sure whether that is changing in the current iOS Beta – my iOS devices are too old to run it, and I don’t like to throw these things away :-)

Sample TaskPaper file for testing import
Phase I: @duration(4d 4h) @effort(4w 1d 1h 48m) @complete(46%)
	- On-site evaluation @duration(1d) @effort(4d 24m) @complete(100%) @person(Ravna, Pham, Johanna, PM) @ref(4)
	- Meeting with venue owners @duration(2h) @effort(6h 6m) @complete(100%) @person(PM, Pham, Ravna) @ref(5) @depends(4=On-site evaluation)
	- Freeform Prototyping @duration(3d) @effort(3w 1h 12m) @complete(32%) @equipment(Lab) @person(Ravna, Pham, Jefri, Johanna) @ref(6) @depends(5=Meeting with venue owners)
		Three days of unstructured experimentation, based on initial impressions from meeting
	- Meet up to show off ideas @duration(2h) @effort(1d 2h 6m) @person(Ravna, Pham, Jefri, Johanna, PM) @ref(7) @depends(6=Freeform Prototyping)
Phase II: @duration(1w) @effort(2w 2d 5h 12m)
	- Mock-ups @duration(4d) @effort(1w 3d 48m)
		- Iteration 1 @duration(1d) @effort(2d 24m) @person(Ravna, Pham) @ref(10) @depends(7=Meet up to show off ideas)
		- Iteration 2 @duration(1d) @effort(2d) @person(Jefri, Johanna) @ref(11) @depends(10=Iteration 1)
		- Iteration 3 @duration(1d) @effort(2d 24m) @person(Ravna, Pham) @ref(12) @depends(11=Iteration 2)
		- Iteration 4 @duration(1d) @effort(2d) @person(Jefri, Johanna) @ref(13) @depends(12=Iteration 3)
	- Review @duration(3d 4h) @effort(4d 24m)
		- Review 1 @duration(4h) @effort(1d 12m) @until(2020-09-07 12:00) @person(Ravna, Pham) @depends(10=Iteration 1)
		- Review 2 @duration(4h) @effort(1d) @until(2020-09-08 13:00) @person(Jefri, Johanna) @depends(11=Iteration 2)
		- Review 3 @duration(4h) @effort(1d 12m) @until(2020-09-11 13:00) @person(Ravna, Pham) @depends(12=Iteration 3)
		- Review 4 @duration(4h) @effort(1d) @person(Jefri, Johanna) @ref(18) @depends(13=Iteration 4)
	- Company-wide presentation @duration(4h) @effort(4h) @depends(18=Review 4)
Release: @milestone
Communication: @duration(2d) @effort(2d 4h) @complete(77%)
	- Evaluate blogging software @duration(1d) @effort(1d) @complete(100%) @material(Coffee=6, Wine=2) @person(Jefri) @ref(21)
	- Blog set-up @duration(7h 50m 36s) @effort(1d) @complete(80%) @equipment(Lab=50%) @person(Jefri=50%) @group(Engineering=2%) @ref(22) @depends(21=Evaluate blogging software)
	- Link up with social sites @duration(1d) @effort(4h) @complete(25%) @person(Jefri=50%) @depends(22=Blog set-up)
JS/JXA source of draft OP -> TaskPaper exporter
(() => {
    'use strict';

    ObjC.import('AppKit');

    // Either an explanatory message displayed in a 
    // dialog,  or a TaskPaper serialization of selected
    // parts of the front project (in macOS OmniPlan 4)
    // copied to the clipboard.

    // Exports either any tasks selected in OP4, or
    // if the title column header is selected,
    // the whole project.

    // An osascript (JXA) version. 
    // At the time of writing some bugs in the omniJS API
    // prevent us from using it to export durations,
    // or handle GUI selections properly.

    // Rob Trew @2020
    // Ver 0.03

    // main :: IO ()
    const main = () => {
        const
            op = Application('OmniPlan'),
            ws = op.windows().filter(
                w => w.document.exists()
            );
        return either(
            alert('OP -> TP3')
        )(
            txt => compose(
                _ => txt,
                alert('Copied to clipboard'),
                copyText
            )(txt)
        )(
            bindLR(
                0 < ws.length ? (
                    Right(ws[0])
                ) : Left(
                    'No documents open in ' + op.name()
                )
            )(w => Right(
                taskPaperFromOPForest(taskString)(
                    w.selectedTasks.exists() ? (
                        commonAncestors(
                            w.selectedTasks()
                        )
                    ) : w.document.childTasks()
                )
            ))
        );
    };

    // -------------- TASKPAPER SERIALIZATION --------------

    // tpTagsForTask :: OP Task -> String
    const tpTagsForTask = x =>
        unwords([ // (property, tag, show)
            ['duration', 'duration', showDuration],
            ['effort', 'effort', showDuration],
            ['completed', 'complete', integerPercent],
            ['endAfterDate', 'due', tpDateString],
            ['endingConstraintDate', 'until', tpDateString],
            ['startingConstraintDate', 'start', tpDateString],
            ['staticCost', 'cost', identity],
            ['taskType', 'milestone', identity],
            ['assignments', 'assigns', showAssigns],
            ['dependents', 'ref', showRef],
            ['prerequisites', 'depends', showPrereqs]
        ].flatMap(tpl => {
            const [prop, tag, f] = tpl;
            const v = x[prop]();
            return Boolean(v) ? (
                'prerequisites' !== prop ? (
                    'ref' !== tag ? (
                        'assigns' !== tag ? (
                            'taskType' !== prop ? (
                                [`@${tag}(${f(v)})`]
                            ) : v.startsWith(
                                'milestone'
                            ) ? [`@${tag}`] : []
                        ) : 0 < v.length ? (
                            showAssigns(v)
                        ) : []
                    ) : 0 < v.length ? (
                        [showRef(x)]
                    ) : []
                ) : 0 < v.length ? (
                    showPrereqs(v)
                ) : []
            ) : [];
        }));

    // showPrereqs :: [OP Prerequisite] -> String
    const showPrereqs = ps => {
        const xs = ps.map(p => {
            const task = p.prerequisiteTask;
            return task.id() + '=' + task.name();
        });
        return `@depends(${xs.join(', ')})`;
    };

    // showRef :: Task -> String
    const showRef = x =>
        // @id turns out to be reserved in TaskPaper
        // but we can use @ref, or @ID or some alternative.
        `@ref(${x.id()})`;

    // showAssigns :: [Assignment] -> String
    const showAssigns = xs => [
        unwords(map(gp => {
            const
                tag = last(words(gp[0][0])),
                kvs = gp.map(x => {
                    const q = x[2];
                    return q !== 1 ? (
                        `${x[1]}=${showAsPercentString(x[2])}`
                    ) : str(x[1]);
                }).join(', ');
            return `@${tag}(${kvs})`;
        })(
            groupSortOn(fst)(
                xs.map(x => {
                    const r = x.resource;
                    return TupleN(
                        r.resourceType(),
                        r.name(),
                        x.units()
                    );
                })
            )
        ))
    ];

    // showAsPercentString :: Num -> String
    const showAsPercentString = n =>
        n !== Math.floor(n) ? (
            `${parseFloat(n.toFixed(2)) * 100}%`
        ) : n.toString();

    // taskString :: String -> Task -> String
    const taskString = indent =>
        task => {
            const
                blnIndent = Boolean(indent),
                note = task.note();
            return indent + (
                (
                    blnIndent ? '- ' : ''
                ) + task.name() + (
                    blnIndent ? '' : ':'
                ) + ' ' + tpTagsForTask(task) + (
                    Boolean(note) ? (
                        '\n' + unlines(
                            lines(note).map(
                                x => '\t' + indent + x
                            )
                        )
                    ) : ''
                )
            );
        };

    // taskPaperFromOPForest :: (OP Task -> String) -> 
    // [OP Task] -> String
    const taskPaperFromOPForest = f =>
        tasks => {
            const go = indent => x => {
                const type = x.taskType();
                return f(indent)(x) + (
                    'group task' !== type ? (
                        ''
                    ) : (
                        '\n' + unlines(
                            x.childTasks().map(
                                go('\t' + indent)
                            )
                        )
                    )
                );
            };
            return unlines(
                map(go(''))(tasks)
            );
        };


    // ------------------- DATE STRINGS --------------------

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

    // ---------------- COMPLETION STRINGS -----------------

    // integerPercent :: Float -> String
    const integerPercent = n =>
        `${parseFloat(n.toFixed(2)) * 100}%`;

    // ------------- COMPOUND DURATION STRINGS -------------

    // showDuration :: Int -> String
    const showDuration = intSeconds =>
        flexibleCompoundDurations(' ')('')(5)(8)(
            ['w', 'd', 'h', 'm', 's']
        )(intSeconds);

    // flexibleCompoundDurations :: String -> String -> 
    // Int -> Int -> [String] -> Int -> String
    const flexibleCompoundDurations = separator =>
        nkGap => daysPerWeek => hoursPerDay =>
        xs => n => foldr(timeTags(nkGap))([])(
            zip(weekParts(daysPerWeek)(hoursPerDay)(n))(
                xs
            )
        ).join(separator);

    // timeTags :: String -> (Int, String) -> 
    // [String] -> [String]
    const timeTags = numberLabelGap =>
        nsTuple => xs => {
            const [n, s] = Array.from(nsTuple);
            return 0 < n ? (
                cons(
                    [str(n), s].join(numberLabelGap)
                )(xs)
            ) : xs;
        };

    // weekParts :: Int -> Int -> Int -> [Int]
    const weekParts = daysPerWeek =>
        hoursPerDay => intSeconds => snd(
            mapAccumR(r => x => {
                const [u, m] = 0 < x ? (
                    [x, r % x]
                ) : [1, r];
                return Tuple(
                    quot(r - m)(u)
                )(m);
            })(intSeconds)([
                0, daysPerWeek, hoursPerDay, 60, 60
            ])
        );

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

    // commonAncestors :: [OP Task] -> [OP Task]
    const commonAncestors = items => {
        // Only items which do not descend
        // from other items in the list.
        const
            dct = items.reduce(
                (a, x) => (a[x.id()] = true, a), {}
            );
        return items.filter(
            x => !until(
                y => (null === y) || dct[y.id()]
            )(v => v.parentTask())(x.parentTask())
        );
    };

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

    // copyText :: String -> IO String
    const copyText = s => {
        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
    });

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

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

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

    // cons :: a -> [a] -> [a]
    const cons = x =>
        // A list constructed from the item x,
        // followed by the existing list xs.
        xs => Array.isArray(xs) ? (
            [x].concat(xs)
        ) : 'GeneratorFunction' !== xs
        .constructor.constructor.name ? (
            x + xs
        ) : ( // cons(x)(Generator)
            function* () {
                yield x;
                let nxt = xs.next();
                while (!nxt.done) {
                    yield nxt.value;
                    nxt = xs.next();
                }
            }
        )();

    // 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 (==) :: Eq a => a -> a -> Bool
    const eq = a =>
        // True when a and b are equivalent in the terms
        // defined below for their shared data type.
        b => {
            const t = typeof a;
            return t !== typeof b ? (
                false
            ) : 'object' !== t ? (
                'function' !== t ? (
                    a === b
                ) : a.toString() === b.toString()
            ) : (() => {
                const kvs = Object.entries(a);
                return kvs.length !== Object.keys(b).length ? (
                    false
                ) : kvs.every(([k, v]) => eq(v)(b[k]));
            })();
        };

    // fanArrow (&&&) :: (a -> b) -> (a -> c) -> (a -> (b, c))
    const fanArrow = f =>
        // A function from x to a tuple of (f(x), g(x))
        // ((,) . f <*> g)
        g => x => Tuple(f(x))(
            g(x)
        );

    // foldr :: (a -> b -> b) -> b -> [a] -> b
    const foldr = f =>
        // Note that that the Haskell signature of foldr differs from that of
        // foldl - the positions of accumulator and current value are reversed
        a => xs => [...xs].reduceRight(
            (a, x) => f(x)(a),
            a
        );

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

    // groupBy :: (a -> a -> Bool) -> [a] -> [[a]]
    const groupBy = fEq =>
        // Typical usage: groupBy(on(eq)(f), xs)
        xs => (ys => 0 < ys.length ? (() => {
            const
                tpl = ys.slice(1).reduce(
                    (gw, x) => {
                        const
                            gps = gw[0],
                            wkg = gw[1];
                        return fEq(wkg[0])(x) ? (
                            Tuple(gps)(wkg.concat([x]))
                        ) : Tuple(gps.concat([wkg]))([x]);
                    },
                    Tuple([])([ys[0]])
                ),
                v = tpl[0].concat([tpl[1]]);
            return 'string' !== typeof xs ? (
                v
            ) : v.map(x => x.join(''));
        })() : [])(list(xs));

    // groupSortOn :: Ord b => (a -> b) -> [a] -> [[a]]
    const groupSortOn = f =>
        compose(
            map(map(snd)),
            groupBy(on(eq)(fst)),
            sortBy(comparing(fst)),
            map(fanArrow(f)(identity))
        );

    // identity :: a -> a
    const identity = x =>
        // The identity function.
        x;

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

    // last :: [a] -> a
    const last = xs => (
        // The last item of a list.
        ys => 0 < ys.length ? (
            ys.slice(-1)[0]
        ) : undefined
    )(list(xs));

    // length :: [a] -> Int
    const length = xs =>
        // Returns Infinity over objects without 
        // finite length, enabling zip and zipWith
        // to choose the shorter argument when one 
        // is non-finite, like a cycle or repeat.
        '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);

    // mapAccumR :: (acc -> x -> (acc, y)) -> acc -> [x] -> (acc, [y])
    const mapAccumR = f =>
        // A tuple of an accumulation and a list 
        // obtained by a combined map and fold,
        // with accumulation from right to left.
        acc => xs => [...xs].reduceRight((a, x) => {
            const pair = f(a[0])(x);
            return Tuple(pair[0])(
                [pair[1]].concat(a[1])
            );
        }, Tuple(acc)([]));

    // on :: (b -> b -> c) -> (a -> b) -> a -> a -> c
    const on = f =>
        // e.g. groupBy(on(eq)(length))
        g => a => b => f(g(a))(g(b));


    // quot :: Int -> Int -> Int
    const quot = n =>
        m => Math.trunc(n / m);

    // snd :: (a, b) -> b
    const snd = tpl =>
        // Second member of a pair.
        tpl[1];

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


    // str :: a -> String
    const str = x =>
        Array.isArray(x) && x.every(
            v => ('string' === typeof v) && (1 === v.length)
        ) ? (
            x.join('')
        ) : x.toString();


    // take :: Int -> [a] -> [a]
    // take :: Int -> String -> String
    const take = n =>
        // The first n elements of a list,
        // string of characters, or stream.
        xs => 'GeneratorFunction' !== xs
        .constructor.constructor.name ? (
            xs.slice(0, n)
        ) : [].concat.apply([], Array.from({
            length: n
        }, () => {
            const x = xs.next();
            return x.done ? [] : [x.value];
        }));

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

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

    // unwords :: [String] -> String
    const unwords = xs =>
        // A space-separated string derived
        // from a list of words.
        xs.join(' ');

    // words :: String -> [String]
    const words = s =>
        // List of space-delimited sub-strings.
        s.split(/\s+/);


    // zip :: [a] -> [b] -> [(a, b)]
    const zip = xs =>
        // Use of `take` and `length` here allows for zipping with non-finite
        // lists - i.e. generators like cycle, repeat, iterate.
        ys => (([xs_, ys_]) => {
            const
                n = Math.min(...[xs_, ys_].map(length)),
                vs = take(n)(ys_);
            return take(n)(xs_).map(
                (x, i) => Tuple(x)(vs[i])
            );
        })([xs, ys].map(list));

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

OmniPlan source file:
New Product Development.oplx.zip (24.8 KB)

I haven’t found much by way of a precedent for lists within TaskPaper tags, although OmniFocus does have the following, which might suggest using equals signs with semicolon separators:

  • @repeat-rule(rule) - an ICS repeat rule (see RFC244557), e.g. FREQ=WEEKLY;INTERVAL=1

That’s a good model, and I’ll make the parsing flexible on semicolon vs comma.

FWIW, on precedent and TaskPaper, Jesse Grosjean’s TaskPaper model does envisage comma-separation of tag values:

https://www.taskpaper.com/guide/getting-started/

which (unlike semicolons) are recognized by the TaskPaper 3 search language in the list compare equivalence modifier:

https://www.taskpaper.com/guide/reference/searches/

but I see no reason not to allow for semicolons (in addition to commas) on input here, and the repeat rule analogy seems a good motivation for doing that, especially if, as you have found, TaskPaper comma examples are not very common in the wild.

PS on the export side, I would guess there is value in defaulting to commas, on the grounds that they are part of the TaskPaper standard, and so might prove more useful in TaskPaper 3 (and perhaps other tools).

(There would probably be a mechanism for allowing an option to prefer semicolons in export, though)

( and of course, the source code will be there for inspection and unconstrained adjustment :-)

For cases where a working week of 5 days * 8 hours = 40 is not the prevailing pattern, it might be useful to have 2 more more additional tags for the top level of a TaskPaper outline, to specify the rate at which a day, week, month or year is converted to working hours.

Screenshot 2020-09-12 at 22.27.30

In the GUI we have the Effort Unit Conversion panel of the project inspector,

but I still haven’t found where / if such values can be read in the API, so the working assumption may have to be 8 * 5 etc for the moment …

A WIP update. This draft now seems to be importing @duration, @effort and @estimate tags (ignoring the rest for the moment) on macOS.

I’ll aim to expand that now to the date and other tags.

Pre-α working draft for an iOS test

Turns out to be too long (for the wiki) to post inline:
TaggedTPImport.omnijs.zip (10.1 KB)