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>
This commit is contained in:
MechaCat02
2026-09-18 21:45:39 +02:00
parent ad8ba28313
commit af4464decb
34 changed files with 3078 additions and 61 deletions

221
test/notes.test.ts Normal file
View File

@@ -0,0 +1,221 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, writeFile, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it } from 'node:test';
import {
filterNotes,
NoteNotFound,
notePathFor,
parseNote,
readNoteAt,
readNotes,
renderNote,
splitFrontmatter,
writeNote,
} from '../src/core/notes.ts';
const STAMP = { modifiedAt: '2026-09-15T10:00:00.000Z', bytes: 100 };
async function root(): Promise<string> {
return mkdtemp(join(tmpdir(), 'schulcloud-notes-'));
}
describe('splitFrontmatter', () => {
it('reads a leading block and keeps the body', () => {
const { front, body } = splitFrontmatter('---\ntitle: Erörterung\ndate: 2026-09-15\n---\n\nText hier.\n');
assert.equal(front.title, 'Erörterung');
assert.equal(front.date, '2026-09-15');
assert.equal(body.trim(), 'Text hier.');
});
it('leaves a note that merely starts with a rule alone', () => {
// A horizontal rule with no closing fence must not eat the note.
const { front, body } = splitFrontmatter('---\nkein Frontmatter, nur ein Strich\n');
assert.deepEqual(front, {});
assert.match(body, /kein Frontmatter/);
});
it('accepts a note with no frontmatter at all', () => {
const { front, body } = splitFrontmatter('# Titel\n\nText.');
assert.deepEqual(front, {});
assert.equal(body, '# Titel\n\nText.');
});
it('takes a German date and an inline tag list', () => {
const { front } = splitFrontmatter('---\ndate: 15.09.2026\ntags: [klausur, "aufsatz"]\nfach: Deutsch\n---\nx');
assert.equal(front.date, '2026-09-15');
assert.deepEqual(front.tags, ['klausur', 'aufsatz']);
// "fach" is the German spelling of subject and has to mean the same thing.
assert.equal(front.subject, 'Deutsch');
});
it('keeps unknown keys rather than dropping them', () => {
const { front } = splitFrontmatter('---\ntitle: T\nlehrer: Frau Meier\n---\nx');
assert.equal(front.extra?.lehrer, 'Frau Meier');
});
});
describe('parseNote', () => {
it('falls back to the heading, then to the filename, for a title', () => {
assert.equal(parseNote('a.md', '# Kryptografie\n\nText', STAMP).title, 'Kryptografie');
assert.equal(parseNote('Deutsch/2026-09-15 Erörterung.md', 'nur Text', STAMP).title, 'Erörterung');
});
it('takes the date from the filename when the frontmatter has none', () => {
assert.equal(parseNote('Deutsch/2026-09-15 Erörterung.md', 'x', STAMP).date, '2026-09-15');
});
it('never dates a note from its mtime', () => {
// An import writes every file today; dating a year of lessons "today"
// would make the whole store useless for revision.
assert.equal(parseNote('lose Notiz.md', 'x', STAMP).date, undefined);
});
it('takes the folder as the subject', () => {
assert.equal(parseNote('LF07/2026-09-15 Netze.md', 'x', STAMP).subject, 'LF07');
assert.equal(parseNote('lose.md', 'x', STAMP).subject, undefined);
});
it('round-trips through renderNote', () => {
const rendered = renderNote({ title: 'Erörterung', date: '2026-09-15', subject: 'Deutsch', tags: ['klausur'] }, 'Body');
const note = parseNote('Deutsch/x.md', rendered, STAMP);
assert.equal(note.title, 'Erörterung');
assert.equal(note.date, '2026-09-15');
assert.equal(note.subject, 'Deutsch');
assert.deepEqual(note.tags, ['klausur']);
assert.equal(note.text, 'Body');
});
});
describe('notePathFor', () => {
it('is subject then date then title', () => {
assert.equal(notePathFor({ date: '2026-09-15', subject: 'Deutsch', title: 'Erörterung' }), 'Deutsch/2026-09-15 Erörterung.md');
});
it('reduces a hostile title to one component', () => {
// The title comes from a tool call, so it is untrusted input that becomes
// a filename — the same boundary the file mirror has.
const path = notePathFor({ date: '2026-09-15', subject: '../../etc', title: '../../.ssh/authorized_keys' });
assert.equal(path.split('/').length, 2);
assert.ok(!path.includes('..'), path);
});
});
describe('readNotes', () => {
it('is empty, not an error, for a directory that does not exist', async () => {
assert.deepEqual(await readNotes(join(tmpdir(), 'schulcloud-notes-absent-xyz')), []);
});
it('walks folders, skips dotfiles and non-notes, and sorts newest first', async () => {
const dir = await root();
await mkdir(join(dir, 'Deutsch'), { recursive: true });
await mkdir(join(dir, '.obsidian'), { recursive: true });
await writeFile(join(dir, 'Deutsch', '2026-09-15 Erörterung.md'), 'A');
await writeFile(join(dir, 'Deutsch', '2026-09-22 Analyse.md'), 'B');
await writeFile(join(dir, '.obsidian', 'workspace.md'), 'nope');
await writeFile(join(dir, 'bild.png'), 'nope');
const notes = await readNotes(dir);
assert.deepEqual(notes.map((note) => note.title), ['Analyse', 'Erörterung']);
});
it('refuses to read its way out of the root', async () => {
const dir = await root();
await assert.rejects(() => readNoteAt(dir, '../../etc/passwd'), /traversal/);
});
it('reports a missing note as missing', async () => {
const dir = await root();
await assert.rejects(() => readNoteAt(dir, 'Deutsch/nichts.md'), NoteNotFound);
});
});
describe('writeNote', () => {
it('creates a note with frontmatter at the derived path', async () => {
const dir = await root();
const { note } = await writeNote(dir, { title: 'Erörterung', text: 'Aufbau: These, Argument, Fazit.', subject: 'Deutsch', date: '2026-09-15' });
assert.equal(note.path, 'Deutsch/2026-09-15 Erörterung.md');
assert.equal(note.subject, 'Deutsch');
assert.match(await readFile(join(dir, note.path), 'utf8'), /^---\ntitle: Erörterung\n/);
});
it('appends to the same file when asked, so a lesson stays one note', async () => {
const dir = await root();
await writeNote(dir, { title: 'Erörterung', text: 'Erstens.', subject: 'Deutsch', date: '2026-09-15' });
const { note, appended } = await writeNote(dir, {
title: 'Nachtrag', text: 'Zweitens.', subject: 'Deutsch', date: '2026-09-15',
path: 'Deutsch/2026-09-15 Erörterung.md', append: true,
});
assert.equal(appended, true);
assert.match(note.text, /Erstens\./);
assert.match(note.text, /Zweitens\./);
assert.equal((await readNotes(dir)).length, 1);
});
it('appends by lesson, not by title — a second note in the same lesson has another name', async () => {
// "halt das auch noch fest" mid-lesson carries a new title; deriving the
// path from it would start a second note every time, which is the one
// thing append exists to prevent.
const dir = await root();
await writeNote(dir, { title: 'Erörterung', text: 'Erstens.', subject: 'Deutsch', date: '2026-09-15' });
const { note, appended } = await writeNote(dir, {
title: 'Nachtrag', text: 'Zweitens.', subject: 'Deutsch', date: '2026-09-15', append: true,
});
assert.equal(appended, true);
assert.equal(note.path, 'Deutsch/2026-09-15 Erörterung.md');
assert.equal((await readNotes(dir)).length, 1);
});
it('creates the note when append finds nothing to append to', async () => {
const dir = await root();
const { note, appended } = await writeNote(dir, { title: 'Erstes', text: 'x', subject: 'Deutsch', date: '2026-09-15', append: true });
assert.equal(appended, false);
assert.equal(note.path, 'Deutsch/2026-09-15 Erstes.md');
});
it('does not append across days or subjects', async () => {
const dir = await root();
await writeNote(dir, { title: 'Montag', text: 'a', subject: 'Deutsch', date: '2026-09-15' });
const otherDay = await writeNote(dir, { title: 'Dienstag', text: 'b', subject: 'Deutsch', date: '2026-09-16', append: true });
const otherSubject = await writeNote(dir, { title: 'Netze', text: 'c', subject: 'LF07', date: '2026-09-15', append: true });
assert.equal(otherDay.appended, false);
assert.equal(otherSubject.appended, false);
assert.equal((await readNotes(dir)).length, 3);
});
it('never overwrites: a second note of the same name gets its own file', async () => {
const dir = await root();
await writeNote(dir, { title: 'Test', text: 'eins', subject: 'Deutsch', date: '2026-09-15' });
const { note, appended } = await writeNote(dir, { title: 'Test', text: 'zwei', subject: 'Deutsch', date: '2026-09-15' });
assert.equal(appended, false);
assert.equal(note.path, 'Deutsch/2026-09-15 Test 2.md');
assert.equal((await readNotes(dir)).length, 2);
});
it('cannot be steered out of the notes root by its title', async () => {
const dir = await root();
const { note } = await writeNote(dir, { title: '../../escape', text: 'x', subject: '..', date: '2026-09-15' });
assert.ok(!note.path.includes('..'), note.path);
assert.equal((await readNotes(dir)).length, 1);
});
});
describe('filterNotes', () => {
const notes = [
parseNote('Deutsch/2026-09-15 A.md', 'a', STAMP),
parseNote('LF07/2026-09-22 B.md', 'b', STAMP),
parseNote('lose.md', 'c', STAMP),
];
it('matches a subject as a fragment', () => {
assert.deepEqual(filterNotes(notes, { subject: 'deut' }).map((note) => note.title), ['A']);
});
it('keeps undated notes inside a date window rather than hiding them', () => {
// Excluding them would silently drop every note that arrived without a
// date, which is most of an Apple Notes import.
const titles = filterNotes(notes, { since: '2026-09-20' }).map((note) => note.title);
assert.deepEqual(titles, ['B', 'lose']);
});
});