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:
@@ -29,11 +29,24 @@ function assertDisposable(url: string): void {
|
||||
function snapshot(
|
||||
courses: { id: string; title: string; boardText?: string; files?: { id: string; name: string; size: number }[] }[],
|
||||
rooms: { id: string; name: string; boardText?: string }[] = [],
|
||||
notes: { path: string; title: string; text: string; subject?: string; date?: string }[] = [],
|
||||
): Snapshot {
|
||||
return {
|
||||
crawledAt: new Date(),
|
||||
schoolId: 'school1',
|
||||
failures: [],
|
||||
submissions: [],
|
||||
lessonLog: [],
|
||||
notes: notes.map((n) => ({
|
||||
path: n.path,
|
||||
title: n.title,
|
||||
text: n.text,
|
||||
...(n.subject ? { subject: n.subject } : {}),
|
||||
...(n.date ? { date: n.date } : {}),
|
||||
tags: [],
|
||||
modifiedAt: '2026-09-15T10:00:00.000Z',
|
||||
bytes: n.text.length,
|
||||
})),
|
||||
rooms: rooms.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
@@ -152,6 +165,45 @@ describe('Store', { skip: DB_URL ? false : 'set TEST_DATABASE_URL to run' }, ()
|
||||
assert.ok(diff.changed.some((n) => n.nodeId === 'r1' && n.kind === 'room'), 'a renamed room is reported as changed');
|
||||
});
|
||||
|
||||
it('indexes the user\'s own notes beside the course material', async () => {
|
||||
// The point of the notes store: one search covers what the school
|
||||
// uploaded and what the user wrote down in the lesson.
|
||||
const before = await store.saveSnapshot(
|
||||
snapshot(
|
||||
[{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }],
|
||||
[],
|
||||
[{ path: 'Deutsch/2026-09-15 Erörterung.md', title: 'Erörterung', subject: 'Deutsch', date: '2026-09-15', text: 'These, Argument, Fazit. Frau Meier betont den Schluss.' }],
|
||||
),
|
||||
'full',
|
||||
);
|
||||
const hits = await store.search('Erörterung', { limit: 5 });
|
||||
const note = hits.find((hit) => hit.kind === 'note');
|
||||
assert.ok(note, 'a note is searchable');
|
||||
assert.equal(note.nodeId, 'Deutsch/2026-09-15 Erörterung.md', 'the path is the id get_note takes');
|
||||
assert.equal(note.meta?.subject, 'Deutsch');
|
||||
|
||||
// And an edited note is a change, so what_changed reports it.
|
||||
const after = await store.saveSnapshot(
|
||||
snapshot(
|
||||
[{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }],
|
||||
[],
|
||||
[{ path: 'Deutsch/2026-09-15 Erörterung.md', title: 'Erörterung', subject: 'Deutsch', date: '2026-09-15', text: 'These, Argument, Fazit. Gegenargument nicht vergessen.' }],
|
||||
),
|
||||
'full',
|
||||
);
|
||||
const diff = await store.diff(before, after);
|
||||
assert.ok(diff.changed.some((n) => n.kind === 'note'), 'an edited note is reported as changed');
|
||||
});
|
||||
|
||||
it('keeps notes through a per-course crawl, which never looks at them', async () => {
|
||||
// Notes belong to the account, not to a course, so a per-course refresh
|
||||
// must carry them forward rather than appear to delete them.
|
||||
const before = await store.latestCrawlId();
|
||||
const after = await store.saveSnapshot(snapshot([{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }]), 'c1');
|
||||
const diff = await store.diff(before!, after);
|
||||
assert.ok(!diff.removed.some((n) => n.kind === 'note'), 'a per-course crawl must not delete the notes');
|
||||
});
|
||||
|
||||
it('carries other courses forward on a per-course crawl', async () => {
|
||||
await store.saveSnapshot(
|
||||
snapshot([
|
||||
|
||||
Reference in New Issue
Block a user