52f420fc114dcc7b51a2b4e66818ba2b863c6abf
4 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
48c6cf39d5 |
Lay the notes screen out like a notes app
The list of notes and the note being written are one screen now, two panes: the notes on the left, the open one on the right, side by side where there is room and one at a time on a phone, where the back button returns to the list. The search box moved into the top of that list, and its results *are* the list — searching is a way of finding a note, not a separate place to be, and a tab for it was a tab too many. Emptying the box brings the whole list back. Opening a hit opens that day at the lesson that matched, rather than at the top of a day with six of them. A row has to say what the note holds, so the listing carries it: the subjects a day covers, how many lessons, and the first line actually written in it. One request for the whole list rather than one per note. `plainText` is now one rule in one place for wherever a note is shown rather than edited — the search snippet and the list row both went through their own half-copy of it, and the row's copy rendered a table as `| | |` and left `_Fazit_` wearing its markers. It strips one leading marker, not each in turn, because `## 1. Deutsch` keeps its lesson number and the list rule was eating it. Driven in Firefox at both widths: the list, the search, opening a hit, the jump to the lesson, and the phone's list-then-note. 390 unit tests, 116/117 smoke. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e129fd4b0a |
Keep a pasted note whole, and search the notes from the app
Two things found by using this on real notes. **The paste.** Copying out of Apple Notes put most of the note on the floor. WebKit wraps a copied selection in a single span carrying the computed style of everything in it — `font-weight: 700` included — with the real blocks nested inside. The serializer read that span as inline, so every line collapsed into one paragraph and every word came out bold; switching to the Markdown view then showed what little had survived, which is what "most of the text was gone" was. And because the boldness came from a foreign span's style rather than a tag, the bold button could not remove it. The rule now is that an element holding blocks is a block whatever its tag, and that a container's style is not emphasis — only a span wrapping a single run of text is. A paste this editor cannot read at all (some engines withhold the clipboard from the event) is tidied afterwards instead, but only if something actually arrived, so an empty paste still costs nothing. **The search.** A Suche tab over the user's own notes, reading the files rather than the index: notes reach the index only on a full crawl, so a lesson written this morning would not be findable this morning, which is most of what anyone searches their own notes for. A result names the lesson it matched in, not the day, for the same reason the index indexes day notes per section. Tapping one opens that day in the editor. Driven in Firefox against the real app with a proxied session: the paste, six switches between the two views, bold and unbold on pasted text, the search, and opening a result. 386 unit tests, 114/115 smoke. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |