Export Apple Notes to stdout, and one folder at a time
`console.log` in JXA writes to standard error, not standard output, so `> notes.ndjson` produced an empty file while every note scrolled past on the terminal. Both streams now go through NSFileHandle, which is the only way to be sure which one you are on; the comment says so, because the next person will reach for console.log too. `--folder` was a substring match applied after each note's body had already been read. It now matches a whole path segment — `Schule` takes Schule and everything under it and leaves Musikschule alone — and is checked before the body, which is the one expensive property and is what makes a large library take minutes. The path is recorded relative to the folder asked for, because the import reads its last segment as the subject: `Schule/Deutsch` has to arrive as `Deutsch`, and a note loose in `Schule` has to arrive with no subject at all rather than one called "Schule". Not run: this needs a Mac with Notes, and there is none here. The JXA stream behaviour is the documented one and the folder logic is plain string work, but the script itself is still unexercised.
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
* Run this **on the Mac that has the notes**:
|
||||
*
|
||||
* osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson
|
||||
* osascript -l JavaScript scripts/export-apple-notes.js --folder Deutsch > deutsch.ndjson
|
||||
* osascript -l JavaScript scripts/export-apple-notes.js --folder Schule > schule.ndjson
|
||||
*
|
||||
* then hand the file to `schulcloud note import notes.ndjson`.
|
||||
*
|
||||
@@ -18,9 +18,15 @@
|
||||
* JXA and not AppleScript because it can serialise JSON, and because reading
|
||||
* properties one note at a time is what keeps a locked note from aborting the
|
||||
* run rather than a language preference.
|
||||
*
|
||||
* **Nothing here uses `console.log`.** In JXA it writes to standard *error*,
|
||||
* not standard output — so `> notes.ndjson` produced an empty file while every
|
||||
* note scrolled past on the terminal. Both streams are written through
|
||||
* NSFileHandle below, which is the only way to be sure which one you are on.
|
||||
*/
|
||||
|
||||
ObjC.import('stdlib');
|
||||
ObjC.import('Foundation');
|
||||
|
||||
function run(argv) {
|
||||
const options = parseArguments(argv);
|
||||
@@ -41,23 +47,62 @@ function run(argv) {
|
||||
let skipped = 0;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const note = items[i];
|
||||
const record = readNote(note);
|
||||
// The folder first. The body is the one expensive property — it is
|
||||
// decompressed per note and is what makes a big library take minutes —
|
||||
// and a note in another folder is not worth reading one for.
|
||||
const folder = folderUnder(safe(function () { return folderPath(note.container()); }, ''), options.folder);
|
||||
if (folder === null) continue;
|
||||
|
||||
const record = readNote(note, folder);
|
||||
if (!record) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (options.folder && (record.folder || '').toLowerCase().indexOf(options.folder.toLowerCase()) === -1) continue;
|
||||
// One object per line, so a huge export streams and a bad note costs one line.
|
||||
console.log(JSON.stringify(record));
|
||||
emit(JSON.stringify(record));
|
||||
written++;
|
||||
}
|
||||
|
||||
// stderr, so it never lands in the file being redirected.
|
||||
log('Exported ' + written + ' note(s)' + (skipped > 0 ? ', skipped ' + skipped + ' unreadable' : '') + '.');
|
||||
log(
|
||||
'Exported ' + written + ' note(s)' +
|
||||
(options.folder ? ' from "' + options.folder + '"' : '') +
|
||||
(skipped > 0 ? ', skipped ' + skipped + ' unreadable' : '') +
|
||||
'.',
|
||||
);
|
||||
if (written === 0) {
|
||||
log(
|
||||
options.folder
|
||||
? 'No note is in a folder called "' + options.folder + '". Folder names are matched whole, not as a fragment.'
|
||||
: 'No notes were readable. Grant the terminal access to Notes and try again.',
|
||||
);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function readNote(note) {
|
||||
/**
|
||||
* A note's folder as seen from the one asked for, or null if it is elsewhere.
|
||||
*
|
||||
* Matched by path segment, not substring: `--folder Schule` takes `Schule` and
|
||||
* everything under it, and leaves `Musikschule` alone.
|
||||
*
|
||||
* The path is recorded *relative* to that folder, because the import reads the
|
||||
* last segment as the subject: `Schule/Deutsch` has to arrive as `Deutsch`, and
|
||||
* a note sitting loose in `Schule` has to arrive with no folder at all — a
|
||||
* subject of "Schule" is not a subject. Without `--folder` the full path is
|
||||
* kept, which is the same thing measured from the top.
|
||||
*/
|
||||
function folderUnder(path, wanted) {
|
||||
if (!wanted) return path;
|
||||
const parts = String(path).split('/');
|
||||
const target = wanted.toLowerCase();
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (parts[i].trim().toLowerCase() === target) return parts.slice(i + 1).join('/');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readNote(note, folder) {
|
||||
try {
|
||||
// Read the body first: it is the property a locked note refuses, and
|
||||
// there is no point building a record we cannot fill.
|
||||
@@ -66,7 +111,7 @@ function readNote(note) {
|
||||
id: safe(function () { return note.id(); }, ''),
|
||||
name: safe(function () { return note.name(); }, ''),
|
||||
body: body || '',
|
||||
folder: safe(function () { return folderPath(note.container()); }, ''),
|
||||
folder: folder,
|
||||
created: safe(function () { return iso(note.creationDate()); }, ''),
|
||||
modified: safe(function () { return iso(note.modificationDate()); }, ''),
|
||||
};
|
||||
@@ -77,6 +122,7 @@ function readNote(note) {
|
||||
id: safe(function () { return note.id(); }, ''),
|
||||
name: safe(function () { return note.name(); }, '(unreadable)'),
|
||||
body: '',
|
||||
folder: folder,
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
@@ -128,10 +174,18 @@ function parseArguments(argv) {
|
||||
return options;
|
||||
}
|
||||
|
||||
/** One line of the export, on standard output — the file being redirected. */
|
||||
function emit(line) {
|
||||
write($.NSFileHandle.fileHandleWithStandardOutput, line);
|
||||
}
|
||||
|
||||
/** Progress and problems, on standard error, so they never land in the file. */
|
||||
function log(message) {
|
||||
$.NSFileHandle.fileHandleWithStandardError.writeData(
|
||||
$.NSString.alloc.initWithUTF8String(message + '\n').dataUsingEncoding($.NSUTF8StringEncoding),
|
||||
);
|
||||
write($.NSFileHandle.fileHandleWithStandardError, message);
|
||||
}
|
||||
|
||||
function write(handle, text) {
|
||||
handle.writeData($.NSString.alloc.initWithUTF8String(text + '\n').dataUsingEncoding($.NSUTF8StringEncoding));
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
|
||||
Reference in New Issue
Block a user