#!/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 Schule > schule.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. * * **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); 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]; // 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; } // One object per line, so a huge export streams and a bad note costs one line. emit(JSON.stringify(record)); written++; } // stderr, so it never lands in the file being redirected. 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 ''; } /** * 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. const body = note.body(); return { id: safe(function () { return note.id(); }, ''), name: safe(function () { return note.name(); }, ''), body: body || '', folder: folder, 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: '', folder: folder, 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; } /** 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) { write($.NSFileHandle.fileHandleWithStandardError, message); } function write(handle, text) { handle.writeData($.NSString.alloc.initWithUTF8String(text + '\n').dataUsingEncoding($.NSUTF8StringEncoding)); } function fail(message) { log(message); $.exit(1); }