Files
Schulcloud-MCP/src/cli/notes.ts
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

144 lines
4.6 KiB
TypeScript

import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { convertAppleNote, parseExport } from './apple-notes.ts';
import type { ApiClient, NoteSummary } from './client.ts';
import { writeNote } from '../core/notes.ts';
/**
* `schulcloud note` — the user's own lesson notes from the command line.
*
* The notes live on the server beside the index, so these go through /api like
* everything else here. The exception is `import --out`, which writes files
* directly: a migration of several hundred notes is worth doing offline, and
* the result can be looked at before it goes anywhere.
*/
export interface NoteWriter {
(line: string): void;
}
export async function noteList(
api: ApiClient,
filter: { subject?: string; since?: string; until?: string },
long: boolean,
out: NoteWriter,
): Promise<number> {
const listing = await api.notes(filter);
if (listing.count === 0) {
out(`No notes yet. The server keeps them in ${listing.root}.`);
return 0;
}
for (const note of listing.notes) out(formatLine(note, long));
if (listing.notes.length < listing.count) {
out(`${listing.count - listing.notes.length} more.`);
}
return 0;
}
export async function noteShow(api: ApiClient, path: string, out: NoteWriter): Promise<number> {
const note = await api.note(path);
out(`# ${note.title}`);
const facts = [note.date, note.subject, note.tags.length > 0 ? note.tags.join(', ') : undefined].filter(Boolean);
if (facts.length > 0) out(facts.join(' · '));
out('');
out(note.text ?? '');
return 0;
}
export async function noteAdd(
api: ApiClient,
input: { title: string; text: string; subject?: string; date?: string; tags?: string[]; append?: boolean },
out: NoteWriter,
): Promise<number> {
const note = await api.addNote({ ...input, source: 'cli' });
out(`${note.appended ? 'Appended to' : 'Saved'} ${note.path}`);
return 0;
}
export interface ImportOptions {
/** Write files here instead of sending them to the server. */
outDir?: string;
/** Force every note into one subject, rather than using its Notes folder. */
subject?: string;
dryRun?: boolean;
}
/**
* Migrates an Apple Notes export.
*
* Notes with no text are skipped rather than imported empty: an export always
* has some — locked notes, and notes that are one attachment — and a store
* seeded with blank entries makes every later listing worse.
*/
export async function noteImport(
api: ApiClient | undefined,
file: string,
options: ImportOptions,
out: NoteWriter,
): Promise<number> {
const contents = await readFile(resolve(file), 'utf8');
const exported = parseExport(contents);
if (exported.length === 0) {
out(`${file} holds no notes. Re-run scripts/export-apple-notes.js on the Mac.`);
return 1;
}
let imported = 0;
let empty = 0;
let failed = 0;
const unreadable: string[] = [];
for (const note of exported) {
if (note.error) {
unreadable.push(note.name || note.id);
continue;
}
const converted = convertAppleNote(note, options.subject ? { subject: options.subject } : {});
if (!converted.text.trim()) {
empty++;
continue;
}
if (options.dryRun) {
out(`would import: ${converted.date ?? '????-??-??'} · ${converted.subject ?? '—'} · ${converted.title}`);
imported++;
continue;
}
try {
if (options.outDir) {
const { note: written } = await writeNote(options.outDir, converted);
out(written.path);
} else {
if (!api) throw new Error('No server configured and no --out directory given.');
const written = await api.addNote(converted);
out(written.path);
}
imported++;
} catch (error) {
failed++;
out(`FAILED ${converted.title}: ${error instanceof Error ? error.message : String(error)}`);
}
}
out(
`${options.dryRun ? 'Would import' : 'Imported'} ${imported} of ${exported.length} note(s)` +
(empty > 0 ? `, skipped ${empty} with no text` : '') +
(unreadable.length > 0 ? `, ${unreadable.length} unreadable in Notes` : '') +
(failed > 0 ? `, FAILED ${failed}` : '') +
'.',
);
if (unreadable.length > 0) {
// Almost always locked notes: they are the ones worth naming, because the
// fix is to unlock them in Notes and export again.
out(`Unreadable (locked, or not downloaded from iCloud): ${unreadable.slice(0, 10).join('; ')}`);
}
return failed > 0 ? 1 : 0;
}
function formatLine(note: NoteSummary, long: boolean): string {
const date = note.date ?? ' ';
const subject = note.subject ? `[${note.subject}] ` : '';
if (!long) return `${date} ${subject}${note.title}`;
const tags = note.tags.length > 0 ? ` #${note.tags.join(' #')}` : '';
return `${date} ${String(note.bytes).padStart(7)} ${subject}${note.title}${tags}\n ${note.path}`;
}