What’s the best way I might import a few hundred Apple Notes into TB?
They contain tags and are nested topically in folders.
Thanks!
Art
What’s the best way I might import a few hundred Apple Notes into TB?
They contain tags and are nested topically in folders.
Thanks!
Art
There isn’t a direct method, unless you roll your own workflow in Automator (i.e using AppleScript). In part, having Googled a bit, Apple doesn’t make it easy to export your notes. You can export your Notes data - if the notes are on iCloud - as part of exporting “your data” from your Apple account. That gives you a zip with text notes nested in folders as per the original notes and with any embedded images as separate assets.
However, the latter only helps partially. If you drag in (folder-)nested text notes to Tinderbox, you will get the nesting preserved but you will get a note/container per folder and well as a note for the actual text files.
Sadly Notes doesn’t export to OPML or a useful outline-type format like that.
Also bear in mind any untested assumptions about formatting or embedding in your notes, such as OPML.
If notes are just ordinary text with no exotic styling or embedded assets (e.g. images) you are less likely to encounter further issues with the text in exported-then-imported notes.
So, the limitation is primarily that apple clearly sees Notes as a note creation device rather than as the head of a data interchange workflow.
If I had the need, and the AppleScript skills, I’d use that to read the source Notes and copy/create in Tinderbox. Perhaps an AppleScripter may want to stick their head above the parapet here with a solution (or, to say why this isn’t a good idea).
I can sketch a first draft of a script tomorrow if you want to tell us a little more about the typical pattern, e.g. :
etc …
also:
Plus (forgive my ignorance – I don’t use Notes – how is the tagging done ?)
This may be harder than it seems, @ComplexPoint. In 10.15, I think you’ll find that all Notes scripting and automation is broken. (But if it’s fixed in the latest patch or if you find a workaround, hooray and let me know). In 10.14, current mechanisms work.
I had forgetten that some have already taken the plunge into 10.15
(Using 10.14 out of habit here – I usually wait until Apple is about to produce the next iteration)
Thanks for the leads, all! I found the following app/script that are able to “brute-export” Notes files individually - without folder-nesting, and also including html line-break code which I guess I’ll need to strip out before importing into TB (my files were rtf for the most part, using bold/italics).
Exporter for Notes.app - Export or Backup Notes from Apple's OSX Notes app to Plain Text .txt (from the people behind Write app)
Migrate from Apple Notes | FAQ & Support | Bear App (from the people behind Bear app)
It’s not pretty, and will require some hand-clean-up (284 files, ow), but it’ll have to do for now.
Is your operating system pre-Catalina ?
Mine is 10.14.6, which means that as @eastgate points out, I may be living in a fool’s (or technical conservative’s) paradise in relation to Notes scripting.
I’ll sketch something that works here on Mojave, but unfortunately I don’t have a Catalina machine to hand which I can test on.
Assuming tag values have no spaces and have a hash as character #1 (i.e. ‘#new-projects’ vs ‘# new projects’), then it should be possible to detect and copy (move?) them to $Tags as part of the ingest or once the notes are in Tinderbox. It probably helps if the tags are consistently at the start, or end, of a note to cut down the changes for incorrect detection.
FWIW, still on Mojave here too.
Yes - Catalina, unfortunately.
That’s the plan, @mwra. Although I may just drop them into csv tables, setup attributes, then import.
Well, given that context, let’s start with a small experiment, to see whether we have a basis for a direct import into Tinderbox.
What happens if you run this AppleScript fragment in Script Editor ?
(Or rather, what, if anything, do you see in the Results panel at the foot of Script Editor – you may need to click the small icon for ‘Show or hide the log’ to reveal the results panel)
(Copy and paste the whole of this code, scrolling right down to the last line at end mReturn
)
use AppleScript version "2.4" -- Yosemite (10.10) or later
use scripting additions
on run
tell application "Notes"
set refNotes to a reference to (notes where password protected is false)
set values to {its name, its body, its creation date, its modification date} of refNotes
set tuples to my transpose(values)
end tell
end run
-- transpose :: [[a]] -> [[a]]
on transpose(rows)
script cols
on |λ|(_, iCol)
script cell
on |λ|(row)
item iCol of row
end |λ|
end script
concatMap(cell, rows)
end |λ|
end script
map(cols, item 1 of rows)
end transpose
-- concatMap :: (a -> [b]) -> [a] -> [b]
on concatMap(f, xs)
set lng to length of xs
set acc to {}
tell mReturn(f)
repeat with i from 1 to lng
set acc to acc & (|λ|(item i of xs, i, xs))
end repeat
end tell
return acc
end concatMap
-- map :: (a -> b) -> [a] -> [b]
on map(f, xs)
-- The list obtained by applying f
-- to each element of xs.
tell mReturn(f)
set lng to length of xs
set lst to {}
repeat with i from 1 to lng
set end of lst to |λ|(item i of xs, i, xs)
end repeat
return lst
end tell
end map
-- mReturn :: First-class m => (a -> b) -> m (a -> b)
on mReturn(f)
-- 2nd class handler function lifted into 1st class script wrapper.
if script is class of f then
f
else
script
property |λ| : f
end script
end if
end mReturn
After a couple minutes crunching, the entire text of all 284 notes (along with some header and timestamp info and html tags) is displayed on the Results panel.
I was able to import and explode the text into around 500 notes (some are just duplicate (", date) lines), so cleanup should be relatively simple and I have the original creation dates as a bonus!
Thanks
That’s encouraging …
If that worked, and you can wait until this evening or tomorrow morning, then I think we should be able to more or less automate the whole thing, including tag extraction and (if you are happy to install https://pandoc.org, which a script can use) the clearing up of HTML markup.
There are various ways we could do this, but for the moment, here are two draft scripts for a two-stage second experiment:
Script 1 - copy the current Notes database to the clipboard in an XML format compatible with the Tinderbox clipboard (simplified TBX only, not full enough for opening from a text file)
Script 2 - Paste the XML from the clipboard into the front TBX document as an outline of notes.
NB both of these scripts (below) are drafted in JavaScript for Automation, so in Script Editor you would need to set the language drop-down at top left to JavaScript
rather than AppleScript
.
NB2 You don’t need to have Pandoc installed for these scripts to work, but if you do, the first script will be able to use it to clean up any HTML markup in your Apple Notes documents.
JS drafts behind disclosure triangles below:
Make sure to copy the whole of each script. This first one ends with
return main();
})();
(() => {
'use strict';
ObjC.import('AppKit');
// Copy current Apple Notes database to clipboard as XML
// (in a format compatible with the Tinderbox 8 clipboard)
// Rob Trew 2020
// Ver 0.01
// main :: IO ()
const main = () => {
// A list of generic tree structures,
// representing folders of notes.
// (in the Apple Notes app)
const noteForest = map(fmapTree(hashTagsExtracted))(
forestFromGroups(
groupsFromNotes(
Application('Notes')
.defaultAccount.notes.where({
passwordProtected: false
})
)
)
);
// With text cleaned (of HTML markup) by Pandoc
// if it is installed.
// ( macOS installer at https://pandoc.org )
return copyText(
tbxXMLFromForest(
either(
constant(noteForest)
)(textTranslatedByPandoc(noteForest))(
pandocPath()
)
)
);
};
// -------------------TINDERBOX XML--------------------
// asPlainText :: String -> String
const asPlainText = (sa, fpPandoc) => htmlText =>
// A plain text translation of htmlText, produced by a copy
// of [pandoc](https://pandoc.org) at the given file path.
// ( See the pandocPath() function below )
sa.doShellScript(
`echo "${htmlText}" | ${fpPandoc} -f html -t plain`
);
// forestFromGroups :: [[[a]]] -> [Tree Dict]
const forestFromGroups = groups =>
// A list of folder trees, derived from
// lists of notes, in which each note is
// itself a list of values.
// ('FolderName', 'Name', 'Text', 'Created', 'Modified')
map(group => Node({
Name: group[0][0]
})(
map(
xs => Node(
zip(['Name', 'Text', 'Created', 'Modified'])(
xs.slice(1)
).reduce(
(a, kv) => Object.assign(
a, {
[kv[0]]: kv[1]
}
), {}
)
)([])
)(group)
))(groups);
// groupsFromNotes :: Notes Object -> [[[a]]]
const groupsFromNotes = notesRef =>
// Groups of lists of notes, where
// each note is a list of values.
groupBy(on(eq)(fst))(
sortBy(comparing(fst))(
transpose([
map(lookup('name'))(
notesRef.container()
),
...map(flip(lookup)(notesRef))([
'name', 'body',
'creationDate',
'modificationDate'
])
])
)
);
// hashTagsExtracted :: Dict -> Dict
const hashTagsExtracted = dict => {
// An updated dictionary, with a list of
// any hash tag names extracted from .Name
// .Text, and separately entered as .Tags
const
strName = dict['Name'] || '',
strText = dict['Text'] || '';
return Boolean(strName || strText) ? (() => {
const [tplName, tplText] = [strName, strText].map(
textAndHashTagList
);
const
strPlainText = tplText[0].trim(),
tags = tplName[1].concat(tplText[1]);
return Object.assign({}, dict, {
'Name': tplName[0]
}, Boolean(strPlainText) ? {
'Text': strPlainText
} : {}, 0 < tags.length ? {
'Tags': tags
} : {});
})() : dict;
};
// pandocPath :: IO () -> Either String FilePath
const pandocPath = () => {
// True if [pandoc](https://pandoc.org) is installed,
// and we can use if from HTML to a text format
// like Markdown or MultiMarkdown.
// standardAdditions (for shell script use of pandoc)
try {
return Right(
Object.assign(
Application.currentApplication(), {
includeStandardAdditions: true
})
.doShellScript('command -v pandoc')
);
} catch (e) {
return Left('Pandoc not found.');
}
};
// tbxXMLFromForest :: [Tree Dict] -> XML String
const tbxXMLFromForest = trees =>
unlines([
'<?xml version="1.0" encoding="UTF-8" ?>',
'<tinderbox version="2" revision="12" >',
xmlTag(false)('item')([])(
unlines(
map(
tpl => xmlTag(true)('attribute')(
[Tuple('name')(fst(tpl))]
)(snd(tpl))
)([
Tuple('Name')('ImportedNote'),
Tuple('IsPrototype')('true'),
Tuple('NeverComposite')('true'),
Tuple('KeyAttributes')('Tags;Created;Modified'),
Tuple('HTMLDontExport')('true'),
Tuple('HTMLExportChildren')('false')
])
)
),
unlines(trees.map(
foldTree(x => xs =>
xmlTag(false)('item')(
[Tuple('proto')('ImportedNote')]
)(
// ATTRIBUTES,
unlines(Object.keys(x).flatMap(
k => {
const v = x[k];
return 'Text' !== k ? ([
xmlTag(true)('attribute')([
Tuple('name')(k)
])('Tags' !== k ? (
'Date' !== v.constructor.name ? (
v
) : iso8601Local(v).replace(
'.000Z', 'Z'
)
) : v.join(';'))
]) : []
})) + (
// ANY TEXT,
Boolean(x['Text']) ? (
'\n' + xmlTag(true)('text')([])(x.Text)
) : ''
) + (
// AND ANY CHILDREN.
0 < xs.length ? (
'\n' + unlines(xs)
) : ''
)
)
)
)),
'</tinderbox>'
]);
// textAndHashTagList :: String -> (String, [String])
const textAndHashTagList = s =>
// A tuple of the tag-stripped body text, and
// a list of tag names (without their hash-prefixes).
0 < s.length ? (
Tuple(s.replace(/#\w+\s?/g, ''))(
map(compose(tail, fst))(
regexMatches(/#\w+/g)(s)
)
)
) : Tuple('')([]);
// textTranslatedByPandoc :: Tree Dict -> FilePath -> Tree Dict
const textTranslatedByPandoc = forest => fpPandoc => {
// Any HTML mark up in Text fields cleaned up by Pandoc
// [pandoc](https://pandoc.org) (if installed)
const sa = Object.assign(
Application.currentApplication(), {
includeStandardAdditions: true
});
return forest.map(fmapTree(
dict => {
const txt = dict.Text;
return txt && txt.includes('</') ? (
Object.assign({}, dict, {
'Text': asPlainText(sa, fpPandoc)(txt)
})
) : dict;
}
));
};
// xmlTag :: String -> [(String, String)] -> String
const xmlTag = blnSingleLine =>
// An XML of the given name,
// with any name-value attribute pairs,
// and enclosing a content string.
name => kvs => content =>
`<${name}${
0 < kvs.length ? (
' ' + unwords(kvs.map(
kv => kv[0] + '="' + kv[1] + '"'
))
) : ''
}>${blnSingleLine ? (
content
) : '\n' + content + '\n'}</${name}>`;
// ------------------------JXA-------------------------
// copyText :: String -> IO String
const copyText = s => {
// String copied to general pasteboard.
const pb = $.NSPasteboard.generalPasteboard;
return (
pb.clearContents,
pb.setStringForType(
$(s),
$.NSPasteboardTypeString
),
s
);
};
// iso8601Local :: Date -> String
const iso8601Local = dte =>
new Date(dte - (6E4 * dte.getTimezoneOffset()))
.toISOString();
// -----------------GENERIC FUNCTIONS------------------
// https://github.com/RobTrew/prelude-jxa
// 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
});
// 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) =>
x => fs.reduceRight((a, f) => f(a), x);
// constant :: a -> b -> a
const constant = k =>
_ => k;
// 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;
// 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]));
})();
};
// flip :: (a -> b -> c) -> b -> a -> c
const flip = f =>
1 < f.length ? (
(a, b) => f(b, a)
) : (x => y => f(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.
tree => {
const go = x => Node(f(x.root))(
x.nest.map(go)
);
return go(tree);
};
// foldTree :: (a -> [b] -> b) -> Tree a -> b
const foldTree = f =>
// The catamorphism on trees. A summary
// value obtained by a depth-first fold.
tree => {
const go = x => f(x.root)(
x.nest.map(go)
);
return go(tree);
};
// fst :: (a, b) -> a
const fst = tpl =>
// First member of a pair.
tpl[0];
// group :: [a] -> [[a]]
const group = xs => {
// A list of lists, each containing only equal elements,
// such that the concatenation of these lists is xs.
const go = xs =>
0 < xs.length ? (() => {
const
h = xs[0],
i = xs.findIndex(x => h !== x);
return i !== -1 ? (
[xs.slice(0, i)].concat(go(xs.slice(i)))
) : [xs];
})() : [];
return go(xs);
};
// groupBy :: (a -> a -> Bool) -> [a] -> [[a]]
const groupBy = fEq =>
// Typical usage: groupBy(on(eq)(f), xs)
xs => 0 < xs.length ? (() => {
const
tpl = xs.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([])([xs[0]])
);
return tpl[0].concat([tpl[1]])
})() : [];
// 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
(Array.isArray(xs) || 'string' === typeof xs) ? (
xs.length
) : Infinity;
// lookup :: String -> Dict -> a
const lookup = k =>
// The value returned from obj by method k
// Not a total function – assumes that k exists.
obj => {
const method = obj[k];
return method.exists ? (
method()
) : undefined;
};
// 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 => (Array.isArray(xs) ? (
xs
) : xs.split('')).map(f);
// on :: (b -> b -> c) -> (a -> b) -> a -> a -> c
const on = f =>
// e.g. sortBy(on(compare,length), xs)
g => a => b => f(g(a))(g(b));
// regexMatches :: Regex -> String -> [[String]]
const regexMatches = rgx =>
// All matches of the given (global /g) regex in
strHay => {
let m = rgx.exec(strHay),
xs = [];
while (m)(xs.push(m), m = rgx.exec(strHay));
return xs;
};
// snd :: (a, b) -> b
const snd = tpl => tpl[1];
// sortBy :: (a -> a -> Ordering) -> [a] -> [a]
const sortBy = f =>
xs => xs.slice()
.sort(uncurry(f));
// tail :: [a] -> [a]
const tail = xs =>
// A new list consisting of all
// items of xs except the first.
0 < xs.length ? xs.slice(1) : [];
// 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];
}));
// transpose :: [[a]] -> [[a]]
const transpose = rows =>
// The columns of the input transposed
// into new rows.
// Simpler version of transpose_, assuming input
// rows of even length.
0 < rows.length ? rows[0].map(
(x, i) => rows.flatMap(
x => x[i]
)
) : [];
// uncurry :: (a -> b -> c) -> ((a, b) -> c)
const uncurry = f =>
// A function over a pair, derived
// from a curried function.
(x, y) => f(x)(y);
// unlines :: [String] -> String
const unlines = xs =>
// A linefeed-delimited string constructed
// from the list of lines in xs.
xs.join('\n');
// unwords :: [String] -> String
const unwords = xs => xs.join(' ');
// 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 => {
const
lng = Math.min(length(xs), length(ys)),
vs = take(lng)(ys);
return take(lng)(xs).map(
(x, i) => Tuple(x)(vs[i])
);
};
// MAIN ---
return main();
})();
The second script also ends with
// MAIN ---
return main();
})();
(() => {
'use strict';
// Paste TBX XML as (Tinderbox 8) note(s) and line(s)
// Rob Trew 2019
// Ver 0.1
ObjC.import('AppKit');
// main :: IO ()
const main = () =>
either(alert('Pasting TBX as note(s)'))(x => x)(
bindLR(clipTextLR())(
strClip => isPrefixOf('<?xml')(strClip) ? (() => {
// Well-formed XML in the text clipboard ?
const eXML = $();
return bindLR(
$.NSXMLDocument.alloc
.initWithXMLStringOptionsError(strClip, 0, eXML)
.isNil() ? Left(
'Problem in clipboard XML:\n\n' +
ObjC.unwrap(eXML.localizedDescription)
) : Right(strClip)
)(strXML => {
const tbx = Application('Tinderbox 8');
return (
tbx.activate(),
setClipOfTextType(
'com.eastgate.tinderbox.scrap'
)(strXML),
//delay(0.05),
menuItemClickedLR('Tinderbox 8')([
'Edit', 'Paste'
])
);
});
})() : Left('No XML found in clipboard.')
)
);
// 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
);
};
// menuItemClickedLR :: String -> [String] -> Either String IO String
const menuItemClickedLR = strAppName => lstMenuPath => {
const intMenuPath = lstMenuPath.length;
return intMenuPath > 1 ? (() => {
const
appProcs = Application('System Events')
.processes.where({
name: strAppName
});
return appProcs.length > 0 ? (() => {
Application(strAppName).activate();
return bindLR(
lstMenuPath.slice(1, -1)
.reduce(
(lra, x) => bindLR(lra)(a => {
const menuItem = a.menuItems[x];
return menuItem.exists() ? (
Right(menuItem.menus[x])
) : Left('Menu item not found: ' + x);
})(),
(() => {
const
k = lstMenuPath[0],
menu = appProcs[0].menuBars[0]
.menus.byName(k);
return menu.exists() ? (
Right(menu)
) : Left('Menu not found: ' + k)
})()
)
)(xs => {
const
k = lstMenuPath[intMenuPath - 1],
items = xs.menuItems,
strPath = [strAppName]
.concat(lstMenuPath).join(' > ');
return bindLR(
items[k].exists() ? (
Right(items[k])
) : Left('Menu item not found: ' + k)
)(menuItem => menuItem.enabled() ? (
Right((
menuItem.click(),
'Clicked: ' + strPath
))
) : Left(
'Menu item disabled : ' + strPath
))
})
})() : Left(strAppName + ' not running.');
})() : Left('MenuItemClicked needs a menu path of 2+ items.');
};
// clipTextLR :: () -> Either String String
const clipTextLR = () => {
const v = ObjC.unwrap($.NSPasteboard.generalPasteboard
.stringForType($.NSPasteboardTypeString));
return Boolean(v) && v.length > 0 ? (
Right(v)
) : Left('No utf8-plain-text found in clipboard');
};
// setClipOfTextType :: String -> String -> IO Bool
const setClipOfTextType = utiOrBundleID => txt => {
const pb = $.NSPasteboard.generalPasteboard;
return (
pb.clearContents,
pb.setStringForType(
$(txt),
utiOrBundleID
)
);
};
// 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
});
// bindLR (>>=) :: Either a -> (a -> Either b) -> Either b
const bindLR = m => mf =>
undefined !== m.Left ? (
m
) : mf(m.Right);
// 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;
// isPrefixOf :: String -> String -> Bool
const isPrefixOf = xs => ys =>
ys.startsWith(xs);
// MAIN ---
return main();
})();
The latter (pasting Notes from XML) script is also available as a Keyboard Maestro Macro: Paste TBX XML as note(s) and links.kmmacros.zip (11.5 KB)
If those separate scripts seem to work, then you may find that you can directly use this single script,
(again, run from Script Editor with language set to JavaScript at top left, or from a Keyboard Maestro Execute JavaScript action, or FastScripts-assigned keystroke, etc)
which aims to copy all all Apple Notes (as long as they are not password protected) into the clipboard in a form that can be immediately and directly pasted into an empty Tinderbox document.
(Full one-stop-shop script below – ends at:
// MAIN ---
return main();
})();
If Pandoc is installed on your system, the script can use it to clean any HTML markup back to unmarked plain text.
JavaScript for Automation
(() => {
'use strict';
ObjC.import('AppKit');
// Copy all folders and notes in the Apple Notes database
// (except any which are password-protected)
// to the clipboard for direct pasting as Tinderbox notes
// with extracted $Tag values.
// Rob Trew 2020
// Ver 0.05
// main :: IO ()
const main = () => {
// A list of generic tree structures,
// representing folders of notes.
// (in the Apple Notes app)
const
noteForest = map(fmapTree(hashTagsExtracted))(
forestFromGroups(
groupsFromNotes(
Application('Notes')
.defaultAccount.notes.where({
passwordProtected: false
})
)
)
),
strClip = tbxXMLFromForest(
either(
constant(noteForest)
)(textTranslatedByPandoc(noteForest))(
pandocPath()
)
);
return alert('Copy from Notes as Tinderbox')(
either(identity)(constant(
'Apple Notes copied to clipboard as Tinderbox 8 notes.\n\n' +
'( Try pasting into an empty Tinderbox document. )'
))(
bindLR(
isPrefixOf('<?xml')(strClip) ? (
Right(strClip)
) : Left('Tinderbox XML not copied from Notes')
)(
compose(
Right,
setClipOfTextType(
'com.eastgate.tinderbox.scrap'
)
)
)
)
);
};
// -------------------TINDERBOX XML--------------------
// asPlainText :: String -> String
const asPlainText = (sa, fpPandoc) => htmlText =>
// A plain text translation of htmlText, produced by a copy
// of [pandoc](https://pandoc.org) at the given file path.
// ( See the pandocPath() function below )
sa.doShellScript(
`echo "${htmlText}" | ${fpPandoc} -f html -t plain`
);
// forestFromGroups :: [[[a]]] -> [Tree Dict]
const forestFromGroups = groups =>
// A list of folder trees, derived from
// lists of notes, in which each note is
// itself a list of values.
// ('FolderName', 'Name', 'Text', 'Created', 'Modified')
map(grp => Node({
Name: grp[0][0]
})(
map(
xs => Node(
zip(['Name', 'Text', 'Created', 'Modified'])(
xs.slice(1)
).reduce(
(a, kv) => Object.assign(
a, {
[kv[0]]: kv[1]
}
), {}
)
)([])
)(grp)
))(groups);
// groupsFromNotes :: Notes Object -> [[[a]]]
const groupsFromNotes = notesRef =>
// Groups of lists of notes, where
// each note is a list of values.
groupBy(on(eq)(fst))(
sortBy(comparing(fst))(
transpose([
map(lookup('name'))(
notesRef.container()
),
...map(flip(lookup)(notesRef))([
'name', 'body',
'creationDate',
'modificationDate'
])
])
)
);
// hashTagsExtracted :: Dict -> Dict
const hashTagsExtracted = dict => {
// An updated dictionary, with a list of
// any hash tag names extracted from .Name
// .Text, and separately entered as .Tags
const
strName = dict['Name'] || '',
strText = dict['Text'] || '';
return Boolean(strName || strText) ? (() => {
const [tplName, tplText] = [strName, strText].map(
textAndHashTagList
);
const
strPlainText = tplText[0].trim(),
tags = tplName[1].concat(tplText[1]);
return Object.assign({}, dict, {
'Name': tplName[0]
}, Boolean(strPlainText) ? {
'Text': strPlainText
} : {}, 0 < tags.length ? {
'Tags': tags
} : {});
})() : dict;
};
// pandocPath :: IO () -> Either String FilePath
const pandocPath = () => {
// True if [pandoc](https://pandoc.org) is installed,
// and we can use if from HTML to a text format
// like Markdown or MultiMarkdown.
// standardAdditions (for shell script use of pandoc)
try {
return Right(
Object.assign(
Application.currentApplication(), {
includeStandardAdditions: true
})
.doShellScript('command -v pandoc')
);
} catch (e) {
return Left('Pandoc not found.');
}
};
// tbxXMLFromForest :: [Tree Dict] -> XML String
const tbxXMLFromForest = trees =>
unlines([
'<?xml version="1.0" encoding="UTF-8" ?>',
'<tinderbox version="2" revision="12" >',
xmlTag(false)('item')([])(
unlines(map(
tpl => xmlTag(true)('attribute')(
[Tuple('name')(fst(tpl))]
)(snd(tpl))
)([
Tuple('Name')('ImportedNote'),
Tuple('IsPrototype')('true'),
Tuple('NeverComposite')('true'),
Tuple('KeyAttributes')('Tags;Created;Modified'),
Tuple('HTMLDontExport')('true'),
Tuple('HTMLExportChildren')('false')
]))
),
unlines(trees.map(
foldTree(x => xs =>
xmlTag(false)('item')(
[Tuple('proto')('ImportedNote')]
)(
// ATTRIBUTES,
unlines(Object.keys(x).flatMap(
k => {
const v = x[k];
return 'Text' !== k ? ([
xmlTag(true)('attribute')([
Tuple('name')(k)
])('Tags' !== k ? (
'Date' !== v.constructor.name ? (
v
) : iso8601Local(v).replace(
'.000Z', 'Z'
)
) : v.join(';'))
]) : []
})) + (
// ANY TEXT,
Boolean(x['Text']) ? (
'\n' + xmlTag(true)('text')([])(x.Text)
) : ''
) + (
// AND ANY CHILDREN.
0 < xs.length ? (
'\n' + unlines(xs)
) : ''
)
)
)
)),
'</tinderbox>'
]);
// textAndHashTagList :: String -> (String, [String])
const textAndHashTagList = s =>
// A tuple of the tag-stripped body text, and
// a list of tag names (without their hash-prefixes).
0 < s.length ? (
Tuple(s.replace(/#\w+\s?/g, ''))(
map(compose(tail, fst))(
regexMatches(/#\w+/g)(s)
)
)
) : Tuple('')([]);
// textTranslatedByPandoc :: Tree Dict -> FilePath -> Tree Dict
const textTranslatedByPandoc = forest => fpPandoc => {
// Any HTML mark up in Text fields cleaned up by Pandoc
// [pandoc](https://pandoc.org) (if installed)
const sa = Object.assign(
Application.currentApplication(), {
includeStandardAdditions: true
});
return forest.map(fmapTree(
dict => {
const txt = dict.Text;
return txt && txt.includes('</') ? (
Object.assign({}, dict, {
'Text': asPlainText(sa, fpPandoc)(txt)
})
) : dict;
}
));
};
// xmlTag :: String -> [(String, String)] -> String
const xmlTag = blnSingleLine =>
// An XML tag of the given name,
// with any name-value attribute pairs,
// and an enclosed content string.
name => kvs => content =>
`<${name}${
0 < kvs.length ? (
' ' + unwords(kvs.map(
kv => kv[0] + '="' + kv[1] + '"'
))
) : ''
}>${blnSingleLine ? (
content
) : '\n' + content + '\n'}</${name}>`;
// ------------------------JXA-------------------------
// alert :: String -> String -> IO String
const alert = title =>
s => (sa => (
sa.activate(),
sa.displayDialog(s, {
withTitle: title,
buttons: ['OK'],
defaultButton: 'OK'
}),
s
))(Object.assign(Application('System Events'), {
includeStandardAdditions: true
}));
// copyText :: String -> IO String
const copyText = s => {
// String copied to general pasteboard.
const pb = $.NSPasteboard.generalPasteboard;
return (
pb.clearContents,
pb.setStringForType(
$(s),
$.NSPasteboardTypeString
),
s
);
};
// iso8601Local :: Date -> String
const iso8601Local = dte =>
new Date(dte - (6E4 * dte.getTimezoneOffset()))
.toISOString();
// setClipOfTextType :: String -> String -> IO String
const setClipOfTextType = utiOrBundleID =>
txt => {
const pb = $.NSPasteboard.generalPasteboard;
return (
pb.clearContents,
pb.setStringForType(
$(txt),
utiOrBundleID
),
txt
);
};
// -----------------GENERIC FUNCTIONS------------------
// https://github.com/RobTrew/prelude-jxa
// 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
});
// 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) =>
x => fs.reduceRight((a, f) => f(a), x);
// constant :: a -> b -> a
const constant = k =>
_ => k;
// 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;
// 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]));
})();
};
// flip :: (a -> b -> c) -> b -> a -> c
const flip = f =>
1 < f.length ? (
(a, b) => f(b, a)
) : (x => y => f(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.
tree => {
const go = x => Node(f(x.root))(
x.nest.map(go)
);
return go(tree);
};
// foldTree :: (a -> [b] -> b) -> Tree a -> b
const foldTree = f =>
// The catamorphism on trees. A summary
// value obtained by a depth-first fold.
tree => {
const go = x => f(x.root)(
x.nest.map(go)
);
return go(tree);
};
// fst :: (a, b) -> a
const fst = tpl =>
// First member of a pair.
tpl[0];
// groupBy :: (a -> a -> Bool) -> [a] -> [[a]]
const groupBy = fEq => xs =>
// // Typical usage: groupBy(on(eq)(f), xs)
0 < xs.length ? (() => {
const
tpl = xs.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([])([xs[0]])
);
return tpl[0].concat([tpl[1]])
})() : [];
// identity :: a -> a
const identity = x =>
// The identity function. (`id`, in Haskell)
x;
// isPrefixOf :: [a] -> [a] -> Bool
// isPrefixOf :: String -> String -> Bool
const isPrefixOf = xs =>
// True if xs is a prefix of ys.
ys => {
const go = (xs, ys) => {
const intX = xs.length;
return 0 < intX ? (
ys.length >= intX ? xs[0] === ys[0] && go(
xs.slice(1), ys.slice(1)
) : false
) : true;
};
return 'string' !== typeof xs ? (
go(xs, ys)
) : ys.startsWith(xs);
};
// 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
(Array.isArray(xs) || 'string' === typeof xs) ? (
xs.length
) : Infinity;
// lookup :: String -> Dict -> a
const lookup = k =>
// The value returned from obj by method k
// Not a total function – assumes that k exists.
obj => {
const method = obj[k];
return method.exists ? (
method()
) : undefined;
};
// 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 => (Array.isArray(xs) ? (
xs
) : xs.split('')).map(f);
// on :: (b -> b -> c) -> (a -> b) -> a -> a -> c
const on = f =>
// e.g. sortBy(on(compare,length), xs)
g => a => b => f(g(a))(g(b));
// regexMatches :: Regex -> String -> [[String]]
const regexMatches = rgx =>
// All matches of the given (global /g) regex in
strHay => {
let m = rgx.exec(strHay),
xs = [];
while (m)(xs.push(m), m = rgx.exec(strHay));
return xs;
};
// 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];
// sortBy :: (a -> a -> Ordering) -> [a] -> [a]
const sortBy = f =>
xs => xs.slice()
.sort(uncurry(f));
// tail :: [a] -> [a]
const tail = xs =>
// A new list consisting of all
// items of xs except the first.
0 < xs.length ? xs.slice(1) : [];
// 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];
}));
// transpose :: [[a]] -> [[a]]
const transpose = rows =>
// The columns of the input transposed
// into new rows.
// Simpler version of transpose_, assuming input
// rows of even length.
0 < rows.length ? rows[0].map(
(x, i) => rows.flatMap(
x => x[i]
)
) : [];
// uncurry :: (a -> b -> c) -> ((a, b) -> c)
const uncurry = f =>
// A function over a pair, derived
// from a curried function.
(x, y) => f(x)(y);
// unlines :: [String] -> String
const unlines = xs =>
// A linefeed-delimited string constructed
// from the list of lines in xs.
xs.join('\n');
// unwords :: [String] -> String
const unwords = xs => xs.join(' ');
// 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 => {
const
lng = Math.min(length(xs), length(ys)),
vs = take(lng)(ys);
return take(lng)(xs).map(
(x, i) => Tuple(x)(vs[i])
);
};
// MAIN ---
return main();
})();
Brilliant, thanks @ComplexPoint!! I’ll get on these and report results tomorrow
This barebones plain vanilla AppleScript seems to be working reliably here under 10.14.6 (Mojave). It doesn’t require Pandoc or have other dependencies.
tell application "Notes"
repeat with aFolder in folders
repeat with aNote in notes of aFolder
tell aNote to set {theName, theBody, theCreateDate, theModifyDate} to {name, body, creation date, modification date}
tell front document of application "Tinderbox 8"
set myNote to make new note with properties {name:theName}
tell myNote
set value of attribute "Text" to my removeMarkupFromText(theBody)
set value of attribute "Created" to my dateToStr(theCreateDate)
set value of attribute "Modified" to my dateToStr(theModifyDate)
set value of attribute "Container" to name of aFolder -- TB automatically creates if doesn't yet exist!
end tell
end tell
end repeat
end repeat
end tell
# HANDLERS (=subroutines)
to dateToStr(aDate) --> convert AppleScript date to string format that Tinderbox recognizes
tell aDate to return short date string & ", " & time string
end dateToStr
on removeMarkupFromText(theText)
-- see https://developer.apple.com/library/archive/documentation/LanguagesUtilities/Conceptual/MacAutomationScriptingGuide/RemoveMarkupfromHTML.html
set tagDetected to false
set theCleanText to ""
repeat with a from 1 to length of theText
set theCurrentCharacter to character a of theText
if theCurrentCharacter is "<" then
set tagDetected to true
else if theCurrentCharacter is ">" then
set tagDetected to false
else if tagDetected is false then
set theCleanText to theCleanText & theCurrentCharacter as string
end if
end repeat
return theCleanText
end removeMarkupFromText
Formatting tags in the note body are stripped, resulting in plain text in $Text of a Tinderbox note. Images (attachments) aren’t handled. Any folders used in Apple Notes are preserved by placing the notes in like-named containers in Tinderbox. Tinderbox doesn’t need to be told to create a container note if one doesn’t already exist; it just does it! Notes that weren’t in a folder in Apple Notes end up in a general Notes container in Tinderbox. Any further hierarchy in Apple Notes (I think it’s possible to have a Folder within a Folder now, though I haven’t gotten that fancy) is ignored.
Any hashtagged tags in the $Text of Tinderbox notes can easily be extracted and placed in the $Tags attribute by selecting the notes and applying this stamp:
$Tags=runCommand("grep -o '#[a-zA-Z0-9_]\+'",$Text).replace('\r',';').replace('#','')
I was thinking it might be possible to put that in an evaluate
in the AppleScript but got lost in the ‘escaping’ of the special characters and decided it is much quicker and easier just to apply the stamp separately.
For substantial numbers of notes this script chugs along for several minutes. Observing notes being created in Tinderbox or watching the Replies pane in Script Editor can give reassurance that it hasn’t choked on something.
Will be interested to hear if this works in Catalina too.
I’m just so freakin’ impressed by you guys…!!!
The script reports an error - Error on line 433: Error: Can’t convert types.
The section the script stops at is this
// lookup :: String -> Dict -> a
const lookup = k =>
// The value returned from obj by method k
// Not a total function – assumes that k exists.
obj => {
const method = obj[k];
return method.exists ? (
method()
The result panel of the script editor displays
“Error -1700: Can’t convert types.”