Click to expand JS source 0.04
(() => {
'use strict';
// Rob Trew 2020
// First illustrative sketch of exporting
// *selected* TaskPaper items with descendants
// as descendants of the selected Tinderbox note.
// NOT READY FOR PRODUCTION - ILLUSTRATIVE CODE ONLY
// Creates a bare outline at the the insertion point
// (or top level, in the absence of a GUI selection)
// in the front Tinderbox document.
// Ver 0.04
// Edited name of a TBX type from 'URL' to 'url'.
// USER OPTIONS
// TaskPaper tag names explicitly
// mapped to Tinderbox Attribute names and type names
// (Optional – in the absence of entries, the script will
// try to guess the destination Tinderbox attribute and type
// for each TaskPaper tag)
const userMappings = {
// range: {
// tbxName: 'Range',
// tbxType: 'number'
// }
// language: {
// tbxName: 'Taal',
// type: 'boolean'
// },
// dur: {
// tbxName: 'teatime',
// tbxType: 'interval'
// }
};
// ------------------- JXA CONTEXT --------------------
const main = () => {
const ds = Application('TaskPaper').documents;
return either(
alert('TaskPaper -> Tinderbox')
)(
x => JSON.stringify(x, null, 2)
// OR JUST
// x => x
)(
bindLR(
ds.length > 0 ? (
Right(ds.at(0))
) : Left('No TaskPaper documents open')
)(tpDoc => bindLR(
tpDoc.evaluate({
script: tp3Context.toString(),
withOptions: {
optionName: 'someValue'
}
})
)(forest => {
const
tbx = Application('Tinderbox 8'),
tbxDocs = tbx.documents;
return bindLR(
0 < tbxDocs.length ? (
Right(tbxDocs.at(0))
) : Left('No documents open in Tinderbox 8')
)(tinderboxNotesFromTaskPaper(tbx)(forest));
}))
);
};
// ---------------- TASKPAPER CONTEXT -----------------
const tp3Context = (editor, options) => {
const tpMain = () =>
Right(
editor.selection
.selectedItemsCommonAncestors
.filter(notEmpty)
.map(fmapPureTP1(x => ({
name: x.bodyContentString,
attribs: x.attributes
})))
);
// ------ GENERICS FOR TASKPAPER CONTEXT ------
// Left :: a -> Either a b
const Left = x => ({
type: 'Either',
Left: x
});
// Right :: b -> Either a b
const Right = x => ({
type: 'Either',
Right: x
});
// Node :: a -> [Tree a] -> Tree a
const Node = v => xs => ({
type: 'Node',
root: v, // any type of value (consistent across tree)
nest: xs || []
});
// fmapPureTP1 :: (TPItem -> a) -> TPItem -> Tree a
const fmapPureTP1 = f => {
// A specialised variant of fmapPureTP
// which excludes blank lines
// unless they have indented 'descendants'.
const go = x => Node(f(x))(
x.hasChildren ? (
x.children.filter(notEmpty).map(go)
) : []
);
return go;
};
// notEmpty :: TP Node -> Bool
const notEmpty = x =>
x.hasChildren || Boolean(x.bodyContentString.trim());
// ------------
return tpMain();
};
// tinderboxNotesFromTaskPaper :: TBX App ->
// [Tree Dict] -> TBX Doc -> Either String TBX IO ()
const tinderboxNotesFromTaskPaper = tbx =>
forest => tbxDoc => bindLR(
normalizedUserMappingsLR(
readOnlyAttributes()
)(userMappings)
)(dctUserSettings => bindLR(
defaultRosettaFromThesaurusLR(
dctUserSettings
)(prunedThesaurus([
'indent', 'data-type'
])(thesaurusFromTPForest(forest)))
)(defaultRosetta =>
bindLR(checkedRosettaLR(tbx)(tbxDoc)(
defaultRosetta
))(compose(
Right,
tbxOutlineFromTPForest(tbx)(tbxDoc)(
// Parent object for imported notes.
tbxDoc.selectedNote() || tbxDoc
)(forest)
))
));
// ----------- TINDERBOX TARGET ATTRIBUTES ------------
// checkedRosettaLR :: TB Document -> Rosetta ->
// Either Message Rosetta
const checkedRosettaLR = tbx =>
// Either an explanatory message, or a mapping
// dictionary in which all Tinderbox tag names
// (data-tag attribute names) are mapped to
// Tinderbox Attributes which have been found or
// created, and which have a type that is either
// guessed from the TaskPaper tag values, or
// specified in the userMappings dictionary.
tbxDoc => rosetta => {
const
tbDocAttributes = tbxDoc.attributes,
dctReadOnly = readOnlyAttributes(),
tags = Object.keys(rosetta),
problems = tags.flatMap(k => {
const
dctEntry = rosetta[k],
tbxName = dctEntry.tbxName,
refAttr = tbDocAttributes.byName(tbxName);
return refAttr.exists() ? (
dctReadOnly[tbxName] ? (
Left(tbxName + ' is read-only')
) : (
tbxType => (tbxType !== dctEntry.tbxType) && (
tbxType !== 'string'
) ? (
Left(
`@${k.slice(5)} -> ${tbxName} :: ` + (
"The TBX type is '" + tbxType + (
`' (not '${dctEntry.tbxType}')`
)
)
)
) : []
)(refAttr.type())
) : (
newAttrib => (
tbDocAttributes.push(newAttrib),
[]
)
)(tbx.Attribute({
name: tbxName,
type: isKnownTBAttributeType(
dctEntry.tbxType
) ? dctEntry.tbxType : 'string'
}));
});
return 0 < problems.length ? (
Left(problems.map(x => x.Left).join('\n\n'))
) : Right(rosetta);
};
// tbxAttributeFoundOrCreated ::
// TBX App -> TBX Attributes ->
// String -> String -> TBX Attribute
const tbxAttributeFoundOrCreated = tbx =>
// Either a reference to an existing attribute, if found,
// or to a new attribute of the given name and type,
// where type is a string drawn from:
// {boolean,color,date,file,interval,
// list,number,set,string,url}
attribs => strTypeName => attribName => {
const maybeAttrib = attribs.byName(attribName);
return maybeAttrib.exists() ? (
maybeAttrib
) : (() => {
const newAttrib = tbx.Attribute({
name: attribName,
type: strTypeName
});
return (
attribs.push(newAttrib),
newAttrib
);
})();
};
// normalizedUserMappingsLR :: Dict -> Dict ->
// Either String Dict
const normalizedUserMappingsLR = readOnlys =>
dctMappings => {
const
leftsRights = partitionEithers(
Object.keys(dctMappings).map(k => {
const
attrPrefix = 'data-',
attrName = k.startsWith(attrPrefix) ? (
k
) : attrPrefix + k,
v = dctMappings[k];
return 'string' !== typeof v ? (() => {
const
ps = Object.getOwnPropertyNames(v),
[mbNameKey, mbTypeKey] = [
x => x.endsWith('ame'),
x => x.endsWith('ype'),
].map(flip(find)(ps)),
tbName = mbNameKey.Nothing ? (
k
) : v[mbNameKey.Just].trim() || k,
tbType = mbTypeKey.Nothing ? (
undefined
) : v[mbTypeKey.Just].trim();
return readOnlys[tbName] ? (
Left(`@${k} -> ${tbName} :: '${tbName}'` + (
' is a read-only Tinderbox attribute.'
))
) : isKnownTBAttributeType(tbType) || (
!Boolean(tbType)
) ? (
Right(TupleN(attrName, tbName, tbType))
) : Left(`@${k} -> ${tbName} :: '${tbType}'` + (
' is not a known TBX Attribute type.\n\n' + (
'The known types are (a-z):\n' + (
bulleted(knownTBAttributeTypeNames())
)
)
))
})() : readOnlys[v] ? (
Left(`@${k} -> ${v} :: ${v} is ` + (
' is a read-only Tinderbox attribute.'
))
) : Right(TupleN(attrName, v, 'string'))
})
);
return 0 < leftsRights[0].length ? (
Left(unlines(leftsRights[0]))
) : Right(leftsRights[1].reduce(
(a, triple) => Object.assign(a, {
[triple[0]]: {
'tbxName': triple[1],
'tbxType': triple[2]
}
}), {}
))
};
// defaultRosettaFromThesaurus :: Thesaurus -> Either String Rosetta
const defaultRosettaFromThesaurusLR = userMappings =>
//
thesaurus => Right(Object.keys(thesaurus).reduce(
(a, k) => {
const maybeSetting = userMappings[k];
return Object.assign(a, {
[k]: {
tbxName: tbxAttrName(userMappings)(k),
tbxType: Boolean(maybeSetting) && (
Boolean(maybeSetting.tbxType)
) ? (
maybeSetting.tbxType
) : tbTypeGuess(thesaurus[k] || [])
}
})
}, {}
));
// tpAttrName :: String -> String
const tpAttrName = k =>
'data-' + k[0].toLocaleLowerCase() + k.slice(1);
// tbxAttrName :: Dict -> String -> String
const tbxAttrName = dctMappings =>
k => {
// Where k is assumed to have the prefix 'data-'
const maybeMapping = dctMappings[k];
return (
maybeMapping ? (
maybeMapping.tbxName // Could still be undefined.
) : undefined
) || firstUpper(k.slice(5));
};
// firstUpper :: String -> String
const firstUpper = k =>
k[0].toLocaleUpperCase() + k.slice(1)
// tbTypeGuess :: String -> String
const tbTypeGuess = xs => {
const
rgxDate = /^[0-9]+\-[0-9]+/,
rgxInterval = /^\d\d:\d\d$/;
return xs.every(x => 0 === x.length) ? (
'boolean'
) : xs.every(x => !isNaN(x)) ? (
'number'
) : xs.every(x => rgxDate.test(x)) ? (
'date'
) : xs.every(x => rgxInterval.test(x)) ? (
'interval'
) : xs.some(x => x.includes(',')) ? (
'list'
) : xs.some(x => x.includes(';')) ? (
'set'
) : 'string';
};
// knownTBAttributeTypeNames :: () -> [String]
const knownTBAttributeTypeNames = () => [
'boolean',
'color',
'date',
'file',
'interval',
'list',
'number',
'set',
'string',
'url'
];
// tbAttributeTypeNameListing :: () -> String
const tbAttributeTypeNameListing = () =>
knownTBAttributeTypeNames();
// isKnownTBAttributeType :: String -> Bool
const isKnownTBAttributeType = k =>
// True if k is a known Tinderbox type name;
knownTBAttributeTypeNames().includes(k);
// ------------- TASKPAPER TAG THESAURUS --------------
// thesaurusFromTPForest :: Forest Dict -> Thesaurus
const thesaurusFromTPForest = forest =>
concatThesaurus(
map(foldTree(
x => xs => mappendThesaurus(
pureThesaurus(x.attribs)
)(concatThesaurus(xs))
))(forest)
);
// ----------- GENERIC THESAURUS FUNCTIONS -----------
// concatThesaurus :: [Thesaurus] -> Thesaurus
const concatThesaurus = xs =>
// A chain of Thesauri concatenated into one.
0 < xs.length ? (
foldl1(mappendThesaurus)(xs)
) : {};
// prunedThesaurus :: String -> Thesaurus -> Thesaurus
const prunedThesaurus = excludedKeys =>
// A partial copy of a Thesaurus, without
// entries for a list of excluded keys.
thesaurus => Object.keys(thesaurus).flatMap(
k => excludedKeys.includes(k) ? (
[]
) : [k]
).reduce((a, k) => (a[k] = thesaurus[k], a), {});
// mappendThesaurus :: Thesaurus -> Thesaurus -> Thesaurus
const mappendThesaurus = x => y =>
// Two thesauri combined into one.
nub(Object.keys(x).concat(
Object.keys(y)
)).reduce(
(a, k) => Object.assign(a, {
[k]: nub(
(x[k] || [])
.concat(y[k] || [])
)
}), {}
);
// pureThesaurus :: Dict -> Thesaurus
const pureThesaurus = dct =>
// A dictionary lifted to a Thesaurus,
// in which all values are lifted into lists.
Object.keys(dct).reduce(
(a, k) => Object.assign(a, {
[k]: [dct[k]]
}), {}
);
// --------------- TINDERBOX FUNCTIONS ----------------
const readOnlyAttributes = () => ({
'AdornmentCount': true,
'Associates': true,
'ChildCount': true,
'Created': true,
'Creator': true,
'DescendantCount': true,
'DisplayName': true,
'HTMLExportPath': true,
'ID': true,
'ImageCount': true,
'InboundLinkCount': true,
'IsAdornment': true,
'IsAlias': true,
'IsComposite': true,
'LastFetched': true,
'Modified': true,
'NoteURL': true,
'OutboundLinkCount': true,
'OutlineDepth': true,
'OutlineOrder': true,
'Path': true,
'Places': true, // reserved
'PlainLinkCount': true,
'ReadCount': true,
'SelectionCount': true,
'SiblingOrder': true,
'TextLength': true,
'TextLinkCount': true,
'WebLinkCount': true,
'WordCount': true
});
// tbxOutlineFromTPForest :: TBX Application ->
// TBX (Note | Document) -> Dict -> [Tree Dict] -> [Tree String]
const tbxOutlineFromTPForest = tbxApp =>
tbxDoc => outlineParent => forest => rosetta => {
const
ks = Object.keys(rosetta).map(
k => rosetta[k].tbxName
),
importProtoType = (() => {
const
prototype = tbxApp.Note({
name: 'importPrototype'
});
tbxDoc.notes.push(prototype);
const protoAttribs = prototype.attributes;
return (
protoAttribs.byName(
'KeyAttributes'
).value = ks.join(';'),
protoAttribs.byName('IsPrototype').value = 'true',
prototype
);
})();
const go = parent => tpDict => {
const
// Initialization of a new TBX Note object.
nodeVal = tpDict.root,
newNote = tbxApp.Note({
name: nodeVal.name || ''
});
return Node(
(
// Effect:
// The new note is appended to
// the child list of some parent.
parent.notes.push(newNote),
(() => {
const
attrs = newNote.attributes,
tags = nodeVal.attribs;
attrs.byName('Prototype')
.value = 'importPrototype';
Object.keys(tags).forEach(k => {
if (!['indent', 'data-type'].includes(k)) {
const
entry = rosetta[k],
tbxName = entry.tbxName,
tbxType = entry.tbxType;
if (
attrs.byName(tbxName).exists()
) {
attrs.byName(tbxName).value = (
'boolean' === tbxType ? (
'true'
) : ['list', 'set']
.includes(tbxType) ? (
asTbxList(tags[k])
) : tags[k]
);
}
}
})
})(),
// Value for .root of returned Node.
newNote.name()
)
)(
// Recursion over any descendants,
// with the new TBX note as an import parent.
tpDict.nest.map(go(newNote))
);
};
return map(go(outlineParent))(forest);
};
// asTbxList :: String -> String
const asTbxList = s =>
// Tinderbox list string.
s.split(',')
.map(k => k.trim())
.join(';');
// ------------------------- 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',
withIcon: sa.pathToResource('TaskPaper.icns', {
inBundle: 'Applications/TaskPaper.app'
})
}),
s
);
};
// ---------------- GENERIC FUNCTIONS -----------------
// https://github.com/RobTrew/prelude-jxa
// Just :: a -> Maybe a
const Just = x => ({
type: 'Maybe',
Nothing: false,
Just: x
});
// Nothing :: Maybe a
const Nothing = () => ({
type: 'Maybe',
Nothing: true,
});
// 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 || []
});
// 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);
// bulleted :: [String] -> String -> String
const bulleted = xs =>
xs.map(
x => '' !== x ? '\t- ' + x : x
).join('\n');
// 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));
// 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 (==) :: String -> String -> Bool
const eq = a =>
// True when a and b are equivalent in the terms
// defined below for their shared data type.
b => a === b
// find :: (a -> Bool) -> [a] -> Maybe a
const find = p =>
// Just the first element in xs which
// matches the predicate p, or
// Nothing if no match is found.
xs => xs.constructor.constructor.name !== (
'GeneratorFunction'
) ? (() => {
const
ys = list(xs),
i = ys.findIndex(p);
return -1 !== i ? (
Just(ys[i])
) : Nothing();
})() : findGen(p)(xs);
// 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));
// foldl1 :: (a -> a -> a) -> [a] -> a
const foldl1 = f =>
// Left to right reduction of the non-empty list xs,
// using the binary operator f, with the head of xs
// as the initial acccumulator value.
xs => (
ys => 1 < ys.length ? (
ys.slice(1).reduce(uncurry(f), ys[0])
) : ys[0]
)(list(xs));
// 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];
// initialCap :: String -> String
const initialCap = s =>
s[0].toLocaleUpperCase() + s.slice(1);
// 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 => list(xs).map(f);
// nub :: [a] -> [a]
const nub = xs =>
nubBy(eq)(xs);
// nubBy :: (a -> a -> Bool) -> [a] -> [a]
const nubBy = fEq => {
const go = xs => 0 < xs.length ? (() => {
const x = xs[0];
return [x].concat(
go(xs.slice(1)
.filter(y => !fEq(x)(y))
)
)
})() : [];
return compose(go, list);
};
// partitionEithers :: [Either a b] -> ([a],[b])
const partitionEithers = xs =>
xs.reduce(
(a, x) => undefined !== x.Left ? (
Tuple(a[0].concat(x.Left))(a[1])
) : Tuple(a[0])(a[1].concat(x.Right)),
Tuple([])([])
);
// reverse :: [a] -> [a]
const reverse = xs =>
'string' !== typeof xs ? (
xs.slice(0).reverse()
) : xs.split('').reverse().join('');
// reverseDict :: Dict -> Dict
const reverseDict = dct =>
// A new dictionary with the
// keys and values swapped.
Object.fromEntries(
Object.entries(dct).map(reverse)
);
// root :: Tree a -> a
const root = tree => tree.root;
// showLog :: a -> IO ()
const showLog = (...args) =>
console.log(
args
.map(x => JSON.stringify(x, null, 2))
.join(' -> ')
);
// snd :: (a, b) -> b
const snd = tpl => tpl[1];
// uncurry :: (a -> b -> c) -> ((a, b) -> c)
const uncurry = f =>
// A function over a pair, derived
// from a curried function.
function () {
const
args = arguments,
xy = Boolean(args.length % 2) ? (
args[0]
) : args;
return f(xy[0])(xy[1]);
};
// 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');
// MAIN ---
return main();
})();