Files
Schulcloud-MCP/scripts/export-apple-notes.js
MechaCat02 af4464decb Read the user's own lesson notes, and the class register behind them
Schulcloud says what was uploaded and WebUntis says what was scheduled.
Neither says what was *taught* — which point the teacher laboured, which
example landed, what "will definitely come up". That lives in two places
this server could not reach: the notes the user takes in the lesson, and
WebUntis' class register.

Notes are a directory of Markdown files (NOTES_DIR), not a table. They
have to be writable from a phone in a classroom, readable when Postgres
is down, and outlive this project, and files are the only shape that is
all three — so the files are the truth and the index is a view of them,
the same split as file_texts and the mirror. list_notes and get_note read
disk, so they answer before the first crawl; search, what_changed and all
three German prompts read them alongside the Schulcloud material.

add_note writes one, and is the only thing in this server that writes
anything. That is not a hole in the read-only invariant but a different
store: it is bounded to NOTES_DIR by the same safeComponent/resolveWithin
pair that stops a hostile Schulcloud filename escaping the mirror, so a
note titled ../../.ssh/authorized_keys becomes a filename. Schulcloud and
WebUntis stay GET-only and allowlisted respectively. NOTES_READONLY
refuses writes outright.

Appending targets the *lesson*, not the title: "halt das auch noch fest"
mid-lesson carries a new title, and deriving the path from it would start
a second note every time, which is the one thing append exists to prevent.

Notes.app has no export — its bodies are compressed protobuf and the
iCloud copy is encrypted — so scripting the app is not the clumsy route
to the notes but the only one. scripts/export-apple-notes.js reads them
through AppleScript into one JSON object per line, and `schulcloud note
import` converts the HTML to Markdown, takes the Notes folder as the
subject and the *creation* date as the lesson's date. Attachments cannot
come across; a note that was a photo of the board imports as a line
saying so, because importing it empty would hide the loss.

The class register needed one API property to become cheap:
getLessonTopic2017 answers per *series*, not per period, so a term is
reconstructed by asking about the latest period of each lesson series and
merging back by id — a few dozen calls for a school year rather than one
per lesson. untis_lesson_topics now takes a subject as well as a period
id, and UNTIS_HISTORY_DAYS of register goes into the index under a kind
of its own, so "what did we actually do before the test" is searchable.

Sharing the snapshot rather than duplicating it caught one thing on the
way: the search tool's live path had to learn notes too, or fresh=true
would have quietly disagreed with the index.

305 tests; 88/89 smoke against the local instance, the one failure being
the H5P service that instance does not run. The live smoke could not be
retaken: that session has lapsed and needs a fresh jwt cookie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:46:26 +02:00

141 lines
4.4 KiB
JavaScript
Executable File

#!/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);
}