diff --git a/docs/DEPLOY-NOTES.md b/docs/DEPLOY-NOTES.md index 24a793d..67f1601 100644 --- a/docs/DEPLOY-NOTES.md +++ b/docs/DEPLOY-NOTES.md @@ -156,11 +156,17 @@ If you have notes in Apple Notes, migrate them now — see [NOTES.md](NOTES.md#migrating-out-of-apple-notes). Briefly, on the Mac: ```bash -osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson +# --folder takes that folder and everything under it; leave it off for all notes +osascript -l JavaScript scripts/export-apple-notes.js --folder Schule > notes.ndjson +wc -l notes.ndjson # one line per note schulcloud note import notes.ndjson --dry-run # look first schulcloud note import notes.ndjson ``` +The export runs on the Mac; the import does not have to. The CLI talks to the +server over HTTP, so copying the `.ndjson` to whatever machine already has the +CLI configured is the shorter path. + Import **once**. Re-running creates second copies, because the importer cannot tell an edited note from a new one with the same title. diff --git a/docs/NOTES.md b/docs/NOTES.md index 551712e..f08e930 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -189,11 +189,22 @@ not the clumsy route to your notes — it is the only one. ```bash osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson +osascript -l JavaScript scripts/export-apple-notes.js --folder Schule > schule.ndjson ``` The first run raises a macOS permission dialog ("Terminal wants access to -Notes"); without it every note comes back empty. `--folder Deutsch` exports one -Notes folder. +Notes"); without it every note comes back empty. + +**`--folder Schule` exports one folder and everything under it.** Folder names +are matched whole, not as a fragment, and the path is recorded relative to the +one you named — so `Schule/Deutsch` arrives as the subject `Deutsch`, and a note +sitting loose in `Schule` arrives with no subject rather than one called +"Schule". Reading a note's body is the expensive part, so filtering here rather +than afterwards is also what makes a large library finish. + +The records go to standard output and the progress line to standard error, so +`> notes.ndjson` gets exactly the notes. Check it took: `wc -l notes.ndjson` +should be the number the script reported. Then look at what it would do, and do it: diff --git a/scripts/export-apple-notes.js b/scripts/export-apple-notes.js index 8f29840..78e3b4f 100755 --- a/scripts/export-apple-notes.js +++ b/scripts/export-apple-notes.js @@ -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) { diff --git a/test/apple-notes.test.ts b/test/apple-notes.test.ts index 59f1076..36005d0 100644 --- a/test/apple-notes.test.ts +++ b/test/apple-notes.test.ts @@ -78,6 +78,14 @@ describe('convertAppleNote', () => { assert.equal(convertAppleNote(note, { subject: 'LF07' }).subject, 'LF07'); }); + it('gives no subject to a note the export could not place', () => { + // What `--folder Schule` produces for a note sitting loose in Schule: + // the path relative to the folder asked for, which is nothing. A subject + // of "Schule" would file every such note under a subject that is not one. + assert.equal(convertAppleNote({ ...note, folder: '' }).subject, undefined); + assert.equal(convertAppleNote({ ...note, folder: ' ' }).subject, undefined); + }); + it('does not repeat the title as the first line of the body', () => { const converted = convertAppleNote(note); assert.equal(converted.title, 'Erörterung');