A first sketch below - will probably need some tweaking till it meets your needs.
Generally:
The trick here lies less in importing a particular piece of data from a JSON-encoded JS dictionary (easy enough) as in doing so in a way that:
- Minimizes the writing and testing of new import code
- Generalises enough to maximize scope for reuse next time, with other sets of data.
All of this could be done in AppleScript rather than JS, but it would risk becoming more long-winded. JS is equipped by default with JSON.parse
and other useful library tools, whereas in AppleScript we have to fiddle with Foundation classes.
The difference is between, for example:
JavaScript
// jsonParseLR :: String -> Either String a
const jsonParseLR = s => {
try {
return Right(JSON.parse(s));
} catch (e) {
return Left(`${e.message} (line:${e.line} col:${e.column})`);
}
}
AppleScript
use AppleScript version "2.4"
use framework "Foundation"
use scripting additions
-- jsonParseLR :: String -> Either String a
on jsonParseLR(s)
set ca to current application
set {x, e} to ca's NSJSONSerialization's ¬
JSONObjectWithData:((ca's NSString's stringWithString:s)'s ¬
dataUsingEncoding:(ca's NSUTF8StringEncoding)) ¬
options:0 |error|:(reference)
if x is missing value then
|Left|(e's localizedDescription() as string)
else
if 1 = (x's isKindOfClass:(ca's NSArray)) as integer then
|Right|(x as list)
else
|Right|(item 1 of (x as list))
end if
end if
end jsonParseLR
So the examples and full code below are all in JavaScript:
Scripted import basics
The basic manoeuvre is is to:
- create a new note (usually with a name string)
- and
push
it into the notes collection of an existing TBX note or document
e.g. (as a snippet in some broader context):
const tbx = Application('Tinderbox 8');
// Assuming that `dict` is a dictionary of Key -> Value pairs
// in which one of the keys is probably 'Name'
// Also assuming that `parent` is either the document
// or an existing note:
const
newNote = tbx.Note({
name: dict['Name'] || ''
});
parent.notes.push(newNote);
Adding Attribute values to a note has the same pattern, in the context of a document which may or may not have an Attribute:
- of the given name,
- and type (drawn from the lower-case names of the set of types listed at Attributes: Data Types)
So we might see this kind of pattern (again, in the context of other code)
const maybeAttrib = attribs.byName(attributeName);
return maybeAttrib.exists() ? (
maybeAttrib
) : (() => {
const newAttrib = tbx.Attribute({
name: attributeName,
type: attributeName.endsWith('Date') ? (
'date'
) : strTypeName
});
return (
attribs.push(newAttrib),
newAttrib
);
})();
Importing hierarchically nested data
We can simplify this by:
- writing reusable functions for importing from a very general representation of Tree-structured data,
- and splitting our code into two stages:
- Converting the new data to the general Tree of Dictionaries (or Tree of Strings) structure
- Using existing library code to import from the general Tree structure into Tinderbox.
We can build general trees with a JS ‘constructor’ function which creates a Node.
A Node is a JS object which just links some value (a String, Dictionary, etc), to a (possibly empty) list of similar child objects.
In other words, a Tree consists of a Node which has a list of child Trees.
A Forest is a useful name for one of these lists of Trees. Most outlines for example are Forests rather than Trees – they typically have more than one top-level paragraph.
// 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 || []
});
Once we have read our data into this kind of Tree format, we can work on it with general functions. For example:
fmapTree(f)(tree)
applies the same function f to every node in the given tree.foldTree(f)(tree)
derives a single value from a whole tree - depending on the particular function f, it might return things like:- The total number of items in a tree
- the maximum depth of a tree
- a full list of all the Attribute name keys occurring anywhere in a Tree of Dictionaries.
Mapping data keys to TBX Attribute names and types
Data from an external source may need some pre-processing to get the desired mapping to TBX Attribute names and types.
Preparing a Forest of data for import to Tinderbox can be usefully divided into two stages:
- Deriving the Tree (probably Forest) structure from the structure of the source data,
- Mapping source data keys and types to TBX Attribute names and types.
A tentative first sketch of doing this for Derek’s data above might look something like the code below.
With this particular data we are:
- mapping the
description
key in the imported JSON to the Tinderbox Text attribute. - initially mapping the source data’s
date
key to a custom GoalDate attribute. (Derek may well want to change that name) - Adapting other keys to Camel-cased names without spaces
- Ignoring source data keys which have empty string values
- Making sure that every note has a Name, borrowing either the name of the key which contains the sub-tree (list of child dictionaries), or the same string as the
Text
attribute (itself taken from the source data description)
// MAPPINGS FROM INPUT DICTIONARIES TO TBX ATTRIBUTES
// Dictionary keys -> TBX Attribute names
// tbxKeyValues :: Dict -> Dict
const tbxKeyValues = dict =>
// Mappings of dictionary keys and values
// to TBX attribute names and value types.
Object.keys(dict)
.reduce(
(a, k) => {
const
k1 = k !== 'description' ? (
k !== 'date' ? (
concat(
map(toSentence)(
words(k)
)
)
) : 'GoalDate'
) : 'Text',
v = dict[k],
dctTBX = Boolean(v) ? (
Object.assign(
a, {
[k1]: v
},
)
) : a;
return Boolean(dctTBX.Name) ? (
dctTBX
) : Object.assign(
dctTBX, {
Name: (
dctTBX['ListTitle'] ||
dctTBX['Text']
)
}
);
}, {}
);
Assuming a copy of @derekvan’s data at ~/Desktop/sample.json
First rough draft of full source
(() => {
'use strict';
// Importing from a sample.json file to Tinderbox
// Rob Trew 2020
// Draft 0.05
// Added a couple of function to the library
// (for reporting any ambiguous JSON nesting)
// main :: IO ()
const main = () => {
const fp = '~/Desktop/sample.json';
return either(
alert('Unexpected data')
)(
alert('Imported to Tinderbox')
)(bindLR(
doesFileExist(fp) ? (
Right(readFile(fp))
) : Left('File not found at path: ' + fp)
)(
strJSON => bindLR(jsonParseLR(strJSON))(
xs => bindLR(traverseList(treeFromDict)(xs))(
forest => bindLR(tbxFrontDocLR())(
compose(
// Indented outline log.
Right, unlines,
indentedLinesFromForest(' ')(
append('- ') // Bullet prefix
),
// Notes inserted in Tinderbox 8.
tbxNotesFromForest(
forest.map(
fmapTree(tbxKeyValues)
)
)
)
)
)
)
));
};
// MAPPINGS FROM INPUT DICTIONARIES TO TBX ATTRIBUTES
// Dictionary keys -> TBX Attribute names
// tbxKeyValues :: Dict -> Dict
const tbxKeyValues = dict =>
// Mappings of dictionary keys and values
// to TBX attribute names and value types.
Object.keys(dict)
.reduce(
(a, k) => {
const
v = dict[k],
dctTBX = Boolean(v) ? (
Object.assign(
a, {
[k !== 'description' ? (
k !== 'date' ? (
concat(
words(k)
.map(toSentence)
)
) : 'GoalDate'
) : 'Text']: v
},
)
) : a;
return Boolean(dctTBX.Name) ? (
dctTBX
) : Object.assign(
dctTBX, {
Name: (
dctTBX['ListTitle'] ||
dctTBX['Text']
)
}
);
}, {}
);
// TINDERBOX GENERAL ----------------------------------
// tbxNotesFromForest ::
// [Tree Dict] -> TBX Document -> [Tree Note]
const tbxNotesFromForest = forest => doc => {
const attribs = doc.attributes;
const go = parent => tree => {
const
dict = tree.root,
newNote = tbx.Note({
name: dict['Name'] || ''
});
return (
parent.notes.push(newNote),
Object.keys(dict).reduce(
(a, k) => 'Name' !== k ? (
a.byName(k).value = dict[k],
a
) : a,
newNote.attributes
),
Node(newNote.name())(
tree.nest.map(go(newNote))
)
);
};
const
tbx = Application('Tinderbox 8'),
dctAllKeys = foldTree(
t => ts => Object.assign({}, t, ...ts)
)(Node({})(forest));
return (
// Full set of the attributes
// for the Key-Value Tree.
Object.keys(dctAllKeys)
.map(
k => tbxAttribFoundOrCreated(tbx)(
attribs
)(toLower(typeof dctAllKeys[k]))(k)
),
forest.map(go(doc))
);
};
// tbxAttribFoundOrCreated ::
// TBX App -> TBX Attributes ->
// String -> String -> TBX Attribute
const tbxAttribFoundOrCreated = 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 => attributeName => {
const maybeAttrib = attribs.byName(
attributeName
);
return maybeAttrib.exists() ? (
maybeAttrib
) : (() => {
const newAttrib = tbx.Attribute({
name: attributeName,
type: attributeName.endsWith('Date') ? (
'date'
) : strTypeName
});
return (
attribs.push(newAttrib),
newAttrib
);
})();
};
// tbxFrontDocLR :: Tbx IO () -> Either String Tbx Doc
const tbxFrontDocLR = () => {
// Either the front document in Tinderbox 8, or an
// explanatory message if no documents are open,
// or Tinderbox 8 is not running.
const tbx = Application('Tinderbox 8');
return tbx.running() ? (() => {
const ds = tbx.documents;
return 0 < ds.length ? (
Right(ds.at(0))
) : Left('No documents open in Tinderbox');
})() : Left('Tinderbox 8 is not running.');
};
// 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
);
};
// ----------------------GENERIC-----------------------
// 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 || []
});
// Left :: a -> Either a b
const Left = x => ({
type: 'Either',
Left: x
});
// Right :: b -> Either a b
const Right = x => ({
type: 'Either',
Right: x
});
// append (++) :: [a] -> [a] -> [a]
// append (++) :: String -> String -> String
const append = xs =>
// A list or string composed by
// the concatenation of two others.
ys => xs.concat(ys);
// 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 = strTab =>
s => s.split(/[\r\n]/).map(
x => '' !== x ? strTab + '- ' + x : x
).join('\n');
// compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
const compose = (...fs) =>
x => fs.reduceRight((a, f) => f(a), x);
// concat :: [[a]] -> [a]
// concat :: [String] -> String
const concat = xs =>
0 < xs.length ? (
xs.every(x => 'string' === typeof x) ? (
''
) : []
).concat(...xs) : xs;
// cons :: a -> [a] -> [a]
const cons = x =>
xs => Array.isArray(xs) ? (
[x].concat(xs)
) : 'GeneratorFunction' !== xs.constructor.constructor.name ? (
x + xs
) : ( // Existing generator wrapped with one additional element
function* () {
yield x;
let nxt = xs.next()
while (!nxt.done) {
yield nxt.value;
nxt = xs.next();
}
}
)();
// deleteKey :: String -> Dict -> Dict
const deleteKey = k =>
// A new dictionary, without the key k.
dct => {
const dct2 = {
...dct
};
return (delete dct2[k], dct2);
};
// doesFileExist :: FilePath -> IO Bool
const doesFileExist = fp => {
const ref = Ref();
return $.NSFileManager.defaultManager
.fileExistsAtPathIsDirectory(
$(fp)
.stringByStandardizingPath, ref
) && 1 !== ref[0];
};
// either :: (a -> c) -> (b -> c) -> Either a b -> c
const either = fl =>
fr => e => 'Either' === e.type ? (
undefined !== e.Left ? (
fl(e.Left)
) : fr(e.Right)
) : undefined;
// elem :: Eq a => a -> [a] -> Bool
// elem :: Char -> String -> Bool
const elem = x =>
xs => xs.includes(x);
// foldTree :: (a -> [b] -> b) -> Tree a -> b
const foldTree = f => tree => {
const go = node => f(node.root)(
node.nest.map(go)
);
return go(tree);
};
// fmapTree :: (a -> b) -> Tree a -> Tree b
const fmapTree = f => tree => {
const go = node => Node(f(node.root))(
node.nest.map(go)
);
return go(tree);
};
// fst :: (a, b) -> a
const fst = tpl => tpl[0];
// identity :: a -> a
const identity = x =>
// The identity function.
// (The argument unchanged)
x;
// indentedLinesFromForest :: String -> (a -> String) ->
// [Tree a] -> [String]
const indentedLinesFromForest = strTab =>
// Indented text representation of a list of Trees.
// f is an (a -> String) function defining
// the string representation of tree nodes.
f => trees => {
const go = indent => node => [indent + f(node.root)]
.concat(node.nest.flatMap(go(strTab + indent)));
return trees.flatMap(go(''));
};
// jsonParseLR :: String -> Either String a
const jsonParseLR = s => {
try {
return Right(JSON.parse(s));
} catch (e) {
return Left(`${e.message} (line:${e.line} col:${e.column})`);
}
};
// Lift a binary function to actions.
// liftA2 f a b = fmap f a <*> b
// liftA2 :: Applicative f => (a -> b -> c) -> f a -> f b -> f c
const liftA2 = f => a => b => {
const t = typeName(a);
return (
'Bottom' !== t ? (
'(a -> b)' === t ? (
liftA2Fn
) : 'Either' === t ? (
liftA2LR
) : 'Maybe' === t ? (
liftA2May
) : 'Tuple' === t ? (
liftA2Tuple
) : 'Node' === t ? (
liftA2Tree
) : liftA2List
) : liftA2List
)(f)(a)(b);
};
// liftA2LR :: (a -> b -> c) -> Either d a -> Either d b -> Either d c
const liftA2LR = f => a => b =>
undefined !== a.Left ? (
a
) : undefined !== b.Left ? (
b
) : Right(f(a.Right)(b.Right))
// pureLR :: a -> Either e a
const pureLR = x => Right(x);
// Given a type name string, returns a
// specialised 'pure', where
// 'pure' lifts a value into a particular functor.
// pureT :: String -> f a -> (a -> f a)
const pureT = t => x =>
'List' !== t ? (
'Either' === t ? (
pureLR(x)
) : 'Maybe' === t ? (
pureMay(x)
) : 'Node' === t ? (
pureTree(x)
) : 'Tuple' === t ? (
pureTuple(x)
) : pureList(x)
) : pureList(x);
// readFile :: FilePath -> IO String
const readFile = fp => {
const
e = $(),
ns = $.NSString.stringWithContentsOfFileEncodingError(
$(fp).stringByStandardizingPath,
$.NSUTF8StringEncoding,
e
);
return ObjC.unwrap(
ns.isNil() ? (
e.localizedDescription
) : ns
);
};
// showJSON :: a -> String
const showJSON = x => JSON.stringify(x, null, 2);
// showLog :: a -> IO ()
const showLog = (...args) =>
console.log(
args
.map(JSON.stringify)
.join(' -> ')
);
// toSentence :: String -> String
const toSentence = s =>
// Sentence case - initial string capitalized
// and rest lowercase.
(0 < s.length) ? (
s[0].toUpperCase() + s.slice(1)
.toLowerCase()
) : s;
// toLower :: String -> String
const toLower = s => s.toLocaleLowerCase();
// traverseList :: (Applicative f) => (a -> f b) -> [a] -> f [b]
const traverseList = f =>
// Collected results of mapping each element
// of a structure to an action, and evaluating
// these actions from left to right.
xs => {
const lng = xs.length;
return 0 < lng ? (() => {
const
vLast = f(xs[lng - 1]),
t = vLast.type || 'List';
return xs.slice(0, -1).reduceRight(
(ys, x) => liftA2(cons)(f(x))(ys),
liftA2(cons)(vLast)(pureT(t)([]))
);
})() : [
[]
];
};
// treeFromDict -> Dict -> Either String Tree Dict
const treeFromDict = dict => {
// A generic Tree structure from a dict
// with keys assumed to include no more than
// one key to a *list* value,
// with this pattern applied recursively
// to each child dictionary in such a list.
const go = dct => {
const
kvs = Object.entries(dct),
lists = kvs.filter(
([_, v]) => Array.isArray(v)
),
lng = lists.length;
return 0 < lng ? (
1 < lng ? (
Left(
'Ambiguous structure :: ' +
lng.toString() + (
' multiple sublists in:\n "' +
dct.name + (
'":\n' + bulleted(' ')(
unlines(lists.map(fst))
)
)
)
)
) : (() => {
const [nestName, xs] = lists[0];
return bindLR(traverseList(go)(xs))(
xs => Right(
Node(
Object.assign(
deleteKey(nestName)(
dct
), {
'List title': nestName
}
)
)(xs)
)
);
})()
) : Right(Node(dct)([]))
};
return go(dict);
};
// typeName :: a -> String
const typeName = v => {
const t = typeof v;
return 'object' === t ? (
Array.isArray(v) ? (
'List'
) : null !== v ? (
v.type || 'Dict'
) : 'Bottom'
) : {
'boolean': 'Bool',
'number': 'Num',
'string': 'String',
'function': '(a -> b)'
} [t] || 'Bottom';
};
// unlines :: [String] -> String
const unlines = xs => xs.join('\n');
// words :: String -> [String]
const words = s => s.split(/\s+/);
return main();
})();