5c0b6588550c5c03bc9df3eea4e6cbabdf6b2f8b
2 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dc50b4bcd5 |
Write the notes in an app, a school day at a time
The notes existed but there was nowhere to write them: a CLI command on a laptop, a tool call through Claude, or a file in a Docker volume. None of those is reachable from a phone in a lesson, which is where notes are actually taken. So: `/app`, served only when WEB_PASSWORD is set. A login, the day's notes, and a settings page for the Schulcloud token — the one surface here meant for a person rather than a program. The shape follows how the notes are written: one note per school day, one `##` heading per lesson, prose and lists and tables beneath. That turns out to be the design decision that matters, twice over. First, it is what lets WebUntis earn its keep. Opening a day with no note fills in that day's lessons — numbered, with times, teacher and room, cancellations dropped and substitutions marked. Retyping the timetable is exactly the work the second upstream exists to avoid, and "Stunden ergänzen" tops up a note started before the day ended without touching what is already written. Second, it changes how notes are indexed. A day note is indexed per lesson, not whole: search answers "my own note, Deutsch, 18.09.2026" rather than "my own note, Friday", and `list_notes subject=Deutsch` finds a day whose frontmatter names no subject at all. Indexed whole, every hit would read as a weekday and "what did we do in Deutsch" would match notes whose other five lessons were something else. `lessonHeading` and `subjectFromHeading` are a loop — the app writes the heading, the indexer reads the subject back out — and a test holds them to it. Notes taken in a lesson cannot be retaken, so the editor is built around not losing them: autosave, every keystroke mirrored to local storage, a save when the phone locks, and a fallback to the local copy when the request never arrives. A save that would overwrite a version the editor never saw is refused and the choice handed back — the notes folder is synced and open in more than one place, and a phone must not silently win over a laptop. `replaceNote` is separate from `writeNote` for that reason: never-overwrite is right for `add_note` and exactly wrong for an editor. WEB_PASSWORD is the first credential here a human types, so it is the first that can be guessed: scrypt at startup, never stored or compared in the clear, per-address rate limiting — which is not decoration, since the scrypt cost is itself a denial-of-service vector without it. The session is a signed HttpOnly SameSite=Strict cookie whose key is derived from the password, so changing it logs everyone out and there is no second secret to keep. It opens /api, because a session is the user, and never /mcp, because nothing in a browser speaks MCP. Also here, because the app made them matter: frontmatter now reads the indented `- item` list form editors write, so an Obsidian vault round-trips its tags; and a four-digit folder is a filing scheme, not a subject, so `2026/` does not file a school year under one. 357 tests; 106/107 smoke against the local instance, the one failure being the H5P service that instance does not run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |