@dombett, this is a first draft of a script that accomplishes what you want. Before stepping into the actual reading of the TP file. Could you test with the sample text that the script provides in order to see if it works for you ?
You can test it in Script Editor, for example. I do not have Hazel, but I think there is a hazelProcessFile
handler.
Full code:
(() => {
'use strict';
// @unlocked2412 2020
// CONTACT
// Gabriel - Twitter: @unlocked2412
// First draft of a TaskPaper import into OmniFocus.
// Ver 0.01
// This Mac version imports task titles and notes. Also,
// the following tags:
// - defer
// - flagged
// OmniJS code to be run from a JXA context.
// USER OPTIONS --------------------------------------
const options = {
project: 'TaskPaper Import'
};
// OMNI JS CODE ---------------------------------------
const omniJSContext = opts => {
// main :: IO ()
const main = () => {
const
s = `- An Idea
- Sub-idea
Another idea
- Sub-idea @Communications
- Child item 1 @Errands
- Child item 2
- Sub-idea with note @Errands
Sub-idea note
Another sub-idea with note
Another sub-idea note`
return showJSON(
compose(
ofObjectsFromForest(
projectFoundOrCreated(opts.project)
),
map(
compose(
fmapTree(ofDictFromTpKV),
treeWithNotes
)
),
forestFromTaskPaperString
)(s)
)
};
// GENERIC FUNCTIONS ----------------------------------
// https://github.com/RobTrew/prelude-jxa
// JS Prelude --------------------------------------------------
// Just :: a -> Maybe a
const Just = x => ({
type: 'Maybe',
Nothing: false,
Just: 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,
});
// Tuple (,) :: a -> b -> (a, b)
const Tuple = a =>
b => ({
type: 'Tuple',
'0': a,
'1': b,
length: 2
});
// 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
);
// constant :: a -> b -> a
const constant = k =>
_ => k;
// div :: Int -> Int -> Int
const div = x =>
y => Math.floor(x / y);
// 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]));
})();
};
// filter :: (a -> Bool) -> [a] -> [a]
const filter = p =>
// The elements of xs which match
// the predicate p.
xs => [...xs].filter(p);
// findIndices :: (a -> Bool) -> [a] -> [Int]
// findIndices :: (String -> Bool) -> String -> [Int]
const findIndices = p =>
xs => (
ys => ys.flatMap((y, i) => p(y, i, ys) ? (
[i]
) : [])
)([...xs])
// 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;
};
// foldl :: (a -> b -> a) -> a -> [b] -> a
const foldl = f =>
a => xs => [...xs].reduce(
(x, y) => f(x)(y),
a
);
// fst :: (a, b) -> a
const fst = tpl =>
// First member of a pair.
tpl[0];
// identity :: a -> a
const identity = x =>
// The identity function. (`id`, in Haskell)
x;
// 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);
// matching :: [a] -> (a -> Int -> [a] -> Bool)
const matching = pat => {
// A sequence-matching function for findIndices etc
// findIndices(matching([2, 3]), [1, 2, 3, 1, 2, 3])
// -> [1, 4]
const
lng = pat.length,
bln = 0 < lng,
h = bln ? pat[0] : undefined;
return x => i => src =>
bln && h == x && eq(pat)(
src.slice(i, lng + i)
);
};
// 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));
// nest :: Tree a -> [a]
const nest = tree => {
// Allowing for lazy (on-demand) evaluation.
// If the nest turns out to be a function –
// rather than a list – that function is applied
// here to the root, and returns a list.
const xs = tree.nest;
return 'function' !== typeof xs ? (
xs
) : xs(root(x));
};
// Derive a function from the name of a JS infix operator
// op :: String -> (a -> a -> b)
const op = strOp =>
eval(`(a, b) => a ${strOp} b`);
// partition :: (a -> Bool) -> [a] -> ([a], [a])
const partition = p =>
// A tuple of two lists - those elements in
// xs which match p, and those which don't.
xs => list(xs).reduce(
(a, x) => p(x) ? (
Tuple(a[0].concat(x))(a[1])
) : Tuple(a[0])(a[1].concat(x)),
Tuple([])([])
);
// root :: Tree a -> a
const root = tree => tree.root;
// showJSON :: a -> String
const showJSON = x =>
// Indented JSON representation of the value x.
JSON.stringify(x, null, 2);
// snd :: (a, b) -> b
const snd = tpl => tpl[1];
// span, applied to a predicate p and a list xs, returns a tuple of xs of
// elements that satisfy p and second element is the remainder of the list:
//
// > span (< 3) [1,2,3,4,1,2,3,4] == ([1,2],[3,4,1,2,3,4])
// > span (< 9) [1,2,3] == ([1,2,3],[])
// > span (< 0) [1,2,3] == ([],[1,2,3])
//
// span p xs is equivalent to (takeWhile p xs, dropWhile p xs)
// 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);
};
// splitArrow (***) :: (a -> b) -> (c -> d) -> ((a, c) -> (b, d))
const splitArrow = f =>
// The functions f and g combined in a single function
// from a tuple (x, y) to a tuple of (f(x), g(y))
// (see bimap)
g => tpl => Tuple(f(tpl[0]))(
g(tpl[1])
);
// splitAt :: Int -> [a] -> ([a], [a])
const splitAt = n =>
xs => Tuple(xs.slice(0, n))(
xs.slice(n)
);
// splitOn :: [a] -> [a] -> [[a]]
// splitOn :: String -> String -> [String]
const splitOn = pat => src =>
/* A list of the strings delimited by
instances of a given pattern in s. */
('string' === typeof src) ? (
src.split(pat)
) : (() => {
const
lng = pat.length,
tpl = findIndices(matching(pat))(src).reduce(
(a, i) => Tuple(
fst(a).concat([src.slice(snd(a), i)])
)(lng + i),
Tuple([])(0),
);
return fst(tpl).concat([src.slice(snd(tpl))]);
})();
// 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];
}));
// 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();
};
// 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;
};
// JS Trees ----------------------------------------------------
// forestFromLineIndents :: [(Int, String)] -> [Tree String]
const forestFromLineIndents = tuples => {
const go = xs =>
0 < xs.length ? (() => {
const [n, s] = Array.from(xs[0]);
// Lines indented under this line,
// tupled with all the rest.
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 = splitArrow(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))
);
};
// indentedLinesFromTrees :: String -> (a -> String) ->
// [Tree a] -> [String]
const indentedLinesFromTrees = strTab => f => trees => {
const go = indent => node => [indent + f(node)]
.concat(node.nest.flatMap(go(strTab + indent)));
return trees.flatMap(go(''));
};
// treeWithNotes :: Tree Dict -> Tree Dict
const treeWithNotes = foldTree(item => subtrees => {
const [withNotes, withoutNotes] = Array.from(
partition(
child => child.root.type === 'note'
)(subtrees)
);
return Node(
Object.assign({},
item, {
note: compose(
unlines,
indentedLinesFromTrees('\t')(
compose(
x => x.text,
root
)
)
)(withNotes)
}
)
)(
withoutNotes
)
})
// OmniFocus OmniJS --------------------------------------------
const flattenTpDict = dct => {
const [kvNames, tagNames] = Array.from(
partition(
k => Boolean(dct['tags'][k]) || k === 'flagged'
)(Object.keys(dct.tags))
);
return Object.assign({}, {
text: dct.text,
note: dct.note
}, kvNames.reduce((a, k) =>
Object.assign({},
a, {
[k]: dct['tags'][k]
}
), {}
), {
tags: tagNames.reduce((a, k) =>
Object.assign({},
a, {
[k]: dct['tags'][k]
}
), {}
)
})
}
// ofDictFromTpKV :: JS Dict -> JS Dict
const ofDictFromTpKV = tpDct => {
const
dct = flattenTpDict(tpDct),
propTable = {
'text': 'name',
'note': 'note',
'flagged': 'flagged',
'defer': 'deferDate',
'tags': 'tags'
},
fnTable = {
'text': identity,
'note': identity,
'flagged': constant(true),
'defer': x => new Date(x),
'tags': dct => Object.keys(dct)
};
return foldl(
a => x => {
const
k = propTable[x],
v = dct[x],
f = fnTable[x];
return Object.assign({},
a, {
[k]: f(v)
})
}
)({})(Object.keys(dct))
};
// ofObjectsFromForest :: OF Object -> [Tree String] -> [OF Object]
const ofObjectsFromForest = parent =>
// New Folders, Projects or Tasks appended
// to the children of the given parent,
// which can be a Document, Folder, Project, or Task.
trees => {
const go = parent => tree => {
const item = ofocInsertedChild(parent)(tree.root);
return (
tree.nest.map(go(item)),
item
);
};
return trees.map(go(parent));
};
// ofocInsertedChild :: OF Object -> Dict -> OF Object
const ofocInsertedChild = parent =>
// A new Folder, Project or Task appended
// to the children of the given parent.
dct => {
const
strParentType = parent.constructor.name,
{
name,
tags,
rest
} = dct,
item = new this[{
'Database': 'Folder',
'Folder': 'Project',
'Project': 'Task'
} [strParentType] || 'Task'](
name,
'Database' !== strParentType ? (
parent
) : null
),
itemType = item.constructor.name;
if (itemType !== 'Folder') {
item.addTags(
map(tagFoundOrCreated)(tags)
)
}
return Object.assign(
item,
rest
)
};
// projectFoundOrCreated :: Project Name -> Project Object
const projectFoundOrCreated = strProject =>
projectNamed(strProject) || new Project(strProject)
// tagFoundOrCreated :: Tag Name -> Tag Object
const tagFoundOrCreated = strTag =>
tagNamed(strTag) || new Tag(strTag)
// MAIN -----------------------------------------
return main()
};
// OmniJS Context Evaluation ------------------------------------------------
return 0 < Application('OmniFocus').documents.length ? (
Application('OmniFocus').evaluateJavascript(
`(${omniJSContext})(${JSON.stringify(options)})`
)
) : 'No documents open in OmniFocus.'
})();