In preparation for testing imports, a sample TaskPaper test file generated (using the JXA script below for macOS) from an old OmniGroup sample project.
(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 :-)
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();
})();