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