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

View File

@@ -84,6 +84,22 @@ DATABASE_URL=postgresql://schulcloud:schulcloud@postgres:5432/schulcloud
# file records are immutable.
# CRAWL_INTERVAL_MS=21600000
# ---------------------------------------------------------------------------
# Your own lesson notes (optional — what you wrote down in class)
# ---------------------------------------------------------------------------
# Directory of Markdown files holding your own notes. With it set, the server
# offers list_notes, get_note and add_note, indexes the notes so `search` finds
# them, and the German prompts consult them. Unset = none of that exists.
# docker-compose.yml sets /data/notes and gives it a volume; bind-mount a synced
# folder there instead to write notes from a phone. See docs/NOTES.md.
# NOTES_DIR=/data/notes
# Leave the files alone: list_notes and get_note still work, add_note and
# POST /api/notes are refused. Right for a deployment whose notes are synced in
# from somewhere else and should have exactly one writer.
# NOTES_READONLY=false
# ---------------------------------------------------------------------------
# WebUntis (optional — the timetable, which Schulcloud does not hold)
# ---------------------------------------------------------------------------
@@ -106,6 +122,14 @@ DATABASE_URL=postgresql://schulcloud:schulcloud@postgres:5432/schulcloud
# UNTIS_USER=your.username
# UNTIS_SECRET=
# How far back to read the class register ("Unterrichtsinhalt", plus the notes
# teachers leave on a period) into the search index, in days. Default 180; 0
# turns it off. Costs one timetable call per 90 days plus one per lesson series
# on a full crawl — a few dozen requests for a school year. This is what makes
# "what did we actually do before the test" searchable rather than something to
# reconstruct one tool call at a time.
# UNTIS_HISTORY_DAYS=180
# ---------------------------------------------------------------------------
# Limits (optional — sensible defaults are built in)
# ---------------------------------------------------------------------------