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>
144 lines
4.6 KiB
TypeScript
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}`;
|
|
}
|