#!/usr/bin/env osascript -l JavaScript /** * Exports Apple Notes to one JSON object per line, on stdout. * * 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 * * then hand the file to `schulcloud note import notes.ndjson`. * * Why this exists at all: Notes.app has no export. Its database is a Core Data * store whose bodies are compressed protobuf, and the iCloud copy is encrypted, * so scripting the app is not the clumsy route to the notes — it is the only * one. The first run raises a macOS permission dialog ("Terminal wants access * to Notes"); without it every note comes back empty. * * 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. */ ObjC.import('stdlib'); function run(argv) { const options = parseArguments(argv); const notes = Application('Notes'); notes.includeStandardAdditions = true; let items; try { items = notes.notes(); } catch (error) { return fail( 'Could not read Notes. Grant the terminal access under System Settings → Privacy & Security → ' + 'Automation, then run this again.\n' + error, ); } let written = 0; let skipped = 0; for (let i = 0; i < items.length; i++) { const note = items[i]; const record = readNote(note); 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)); written++; } // stderr, so it never lands in the file being redirected. log('Exported ' + written + ' note(s)' + (skipped > 0 ? ', skipped ' + skipped + ' unreadable' : '') + '.'); return ''; } function readNote(note) { try { // Read the body first: it is the property a locked note refuses, and // there is no point building a record we cannot fill. const body = note.body(); return { id: safe(function () { return note.id(); }, ''), name: safe(function () { return note.name(); }, ''), body: body || '', folder: safe(function () { return folderPath(note.container()); }, ''), created: safe(function () { return iso(note.creationDate()); }, ''), modified: safe(function () { return iso(note.modificationDate()); }, ''), }; } catch (error) { // A locked note, or one iCloud has not downloaded. Reported, not dropped // silently: a missing lesson is worse than a line in the file. return { id: safe(function () { return note.id(); }, ''), name: safe(function () { return note.name(); }, '(unreadable)'), body: '', error: String(error), }; } } /** "Schule/Deutsch" — the import takes the last segment as the subject. */ function folderPath(container) { const parts = []; let current = container; for (let depth = 0; current && depth < 8; depth++) { const name = safe(function () { return current.name(); }, ''); if (!name) break; parts.unshift(name); current = safe(function () { return current.container(); }, null); } return parts.join('/'); } /** * A Date as local ISO, not UTC. * * `toISOString` would shift a note taken at 08:30 in Erfurt back to the * previous day for anything written before 01:00 or 02:00, and the date is * the whole point of the record. */ function iso(date) { if (!date) return ''; const pad = function (value) { return String(value).padStart(2, '0'); }; return ( date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds()) ); } function safe(read, fallback) { try { const value = read(); return value === undefined || value === null ? fallback : value; } catch (error) { return fallback; } } function parseArguments(argv) { const options = { folder: '' }; for (let i = 0; i < argv.length; i++) { if (argv[i] === '--folder' && argv[i + 1]) options.folder = argv[++i]; } return options; } function log(message) { $.NSFileHandle.fileHandleWithStandardError.writeData( $.NSString.alloc.initWithUTF8String(message + '\n').dataUsingEncoding($.NSUTF8StringEncoding), ); } function fail(message) { log(message); $.exit(1); }