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>
206 lines
7.7 KiB
Markdown
206 lines
7.7 KiB
Markdown
# Your own lesson notes
|
|
|
|
Schulcloud holds the material and WebUntis holds the schedule. Neither holds
|
|
what was actually said in the room — which teacher stressed what, the example
|
|
that finally made it click, the aside that turns up in the test. That is in
|
|
whatever you write down during the lesson, and until now it lived somewhere no
|
|
agent could read.
|
|
|
|
This is the third source: a directory of Markdown files the server reads,
|
|
indexes and searches alongside everything else.
|
|
|
|
```
|
|
NOTES_DIR/
|
|
Deutsch/
|
|
2026-09-15 Erörterung.md
|
|
2026-09-22 Sprachanalyse.md
|
|
LF07/
|
|
2026-09-16 Subnetting.md
|
|
Allgemein/
|
|
2026-09-18 Elternabend.md
|
|
```
|
|
|
|
## Why files
|
|
|
|
Three properties, in this order:
|
|
|
|
- **They have to be writable from a classroom.** Whatever you take notes in on a
|
|
phone or a laptop, it can produce text files; nothing can produce rows in the
|
|
Pi's Postgres.
|
|
- **They have to be readable when the index is down.** `list_notes` and
|
|
`get_note` read the disk, so they answer before the first crawl and while
|
|
Postgres is unreachable — the index is a view of the notes, never the notes
|
|
themselves. That is the same split as extracted file text and the mirror.
|
|
- **They have to survive this project.** A directory of dated Markdown is
|
|
greppable, diffable, syncable and still yours if the server is thrown away.
|
|
|
|
## What a note looks like
|
|
|
|
Frontmatter, then the note. Every field is optional, and a plain `.md` file with
|
|
no frontmatter at all is a perfectly good note.
|
|
|
|
```markdown
|
|
---
|
|
title: Erörterung — Aufbau
|
|
date: 2026-09-15
|
|
subject: Deutsch
|
|
tags: [klausur, aufsatz]
|
|
---
|
|
|
|
Dreischritt: These, Argument mit Beleg, Fazit.
|
|
|
|
Frau Meier betont: das **Gegenargument** darf nicht fehlen, sonst gibt es
|
|
Abzug — kam letztes Jahr in der Arbeit dran.
|
|
```
|
|
|
|
What the server reads out of it:
|
|
|
|
| Field | Falls back to |
|
|
|---|---|
|
|
| `title` | the first `# heading`, else the filename without its date |
|
|
| `date` | a `YYYY-MM-DD` the **filename** starts with, else nothing |
|
|
| `subject` | the first folder name (`Deutsch/…`) |
|
|
| `tags` | none |
|
|
| `courseId` | none — set it to tie a note to a Schulcloud course in `search` |
|
|
|
|
`date` also accepts `15.09.2026`, and `fach:` works as a German spelling of
|
|
`subject:`. Anything else in the block is kept but not interpreted.
|
|
|
|
**The file's modification time is never used as the date.** An import writes
|
|
every note today; dating a year of lessons "today" would make the whole store
|
|
useless for revision, which is most of what it is for.
|
|
|
|
## Reading them
|
|
|
|
| Tool | Answers |
|
|
|---|---|
|
|
| `list_notes` | "what did I write down in Deutsch before the test" — filter by subject or date |
|
|
| `get_note` | one note in full, by the path everything else prints |
|
|
| `search` | notes by their contents, next to board text and the inside of PDFs |
|
|
| `what_changed` | notes that appeared or were edited since a date |
|
|
|
|
The `pruefungsvorbereitung`, `zusammenfassung` and `tagesvorbereitung` prompts
|
|
consult them on their own, and are told to say when a note disagrees with the
|
|
uploaded material rather than quietly preferring one.
|
|
|
|
## Writing them
|
|
|
|
Four ways in, all landing in the same files:
|
|
|
|
```bash
|
|
# from the command line, text piped in
|
|
pbpaste | schulcloud note add --title "Subnetting" --subject LF07
|
|
|
|
# from an editor, or anything else that writes files
|
|
$EDITOR "$NOTES_DIR/LF07/2026-09-16 Subnetting.md"
|
|
|
|
# by asking Claude, during or after the lesson
|
|
# "halt fest: Gegenargument nicht vergessen, kam letztes Jahr dran"
|
|
# → add_note, subject Deutsch, today's date
|
|
|
|
# by syncing a folder you already write in — see below
|
|
```
|
|
|
|
`add_note` and `schulcloud note add` take `append`, which adds to the note
|
|
already written for that subject and day instead of starting a second one. That
|
|
is the right choice while a lesson is running: notes accumulate in one file the
|
|
way they do on paper.
|
|
|
|
**This is the only thing in this server that writes anything.** It writes into
|
|
the notes directory and nowhere else: every path component goes through
|
|
`safeComponent` and the result through `resolveWithin`, the same two functions
|
|
that stop a hostile filename escaping the file mirror, so a note titled
|
|
`../../.ssh/authorized_keys` becomes a filename. Schulcloud and WebUntis stay
|
|
strictly read-only — see the invariants in `CLAUDE.md`. Set `NOTES_READONLY=1`
|
|
to refuse writes entirely, which is right when the notes are synced in from
|
|
somewhere else and should have exactly one writer.
|
|
|
|
## Migrating out of Apple Notes
|
|
|
|
Notes.app has no export. Its database is a Core Data store whose bodies are
|
|
compressed protobuf and whose iCloud copy is encrypted, so scripting the app is
|
|
not the clumsy route to your notes — it is the only one.
|
|
|
|
**On the Mac**, from a checkout of this repo:
|
|
|
|
```bash
|
|
osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson
|
|
```
|
|
|
|
The first run raises a macOS permission dialog ("Terminal wants access to
|
|
Notes"); without it every note comes back empty. `--folder Deutsch` exports one
|
|
Notes folder.
|
|
|
|
Then convert and import. Look at it first:
|
|
|
|
```bash
|
|
schulcloud note import notes.ndjson --dry-run
|
|
```
|
|
|
|
```
|
|
would import: 2026-09-15 · Deutsch · Erörterung
|
|
would import: 2026-09-16 · LF07 · Subnetting
|
|
Would import 84 of 91 note(s), skipped 5 with no text, 2 unreadable in Notes.
|
|
```
|
|
|
|
and then for real, either straight into the server:
|
|
|
|
```bash
|
|
schulcloud note import notes.ndjson
|
|
```
|
|
|
|
or into a local directory, to read through before anything is sent anywhere:
|
|
|
|
```bash
|
|
schulcloud note import notes.ndjson --out ~/Notizen
|
|
```
|
|
|
|
What the import does with each note:
|
|
|
|
- **The Notes folder becomes the subject** — its last segment, so `Schule/Deutsch`
|
|
is `Deutsch`. Notes' own default folders (`Notizen`, `Recently Deleted`) are
|
|
ignored rather than becoming a subject. `--subject LF07` overrides all of it.
|
|
- **The creation date becomes the note's date**, because that is the day of the
|
|
lesson. The modification date is whenever you last tidied it up, which is not
|
|
a school day at all.
|
|
- **The HTML becomes Markdown** — headings, lists, checklists, bold, italics and
|
|
links survive; anything else keeps its words and loses its tag.
|
|
- **Attachments do not come across.** A note that was a photo of the board
|
|
imports as a line saying an attachment was there. Better than importing empty:
|
|
you can see which notes still need the picture.
|
|
- **Locked notes cannot be read at all** and are listed by name at the end.
|
|
Unlock them in Notes and export again.
|
|
|
|
Re-running the import creates second copies rather than overwriting, since the
|
|
importer cannot tell an edited note from a new one with the same title. Import
|
|
once, then keep writing in the new place.
|
|
|
|
## Writing them from a phone, afterwards
|
|
|
|
The notes are files, so any sync tool will do and the server does not need to
|
|
know which. Bind-mount the folder instead of using the volume:
|
|
|
|
```yaml
|
|
# docker-compose.yml, under schulcloud-mcp:
|
|
volumes:
|
|
- /home/pi/Notizen:/data/notes
|
|
```
|
|
|
|
Then point Syncthing, Nextcloud, an Obsidian vault or `git` at
|
|
`/home/pi/Notizen` on one side and your phone on the other. New files are picked
|
|
up by the next crawl; nothing has to be told about them.
|
|
|
|
Without any of that, `schulcloud note add` and `add_note` still work — they go
|
|
over the same authenticated `/api` as everything else the CLI does.
|
|
|
|
## What the crawl does with them
|
|
|
|
A **full** crawl reads the directory and indexes every note: title, subject,
|
|
tags and body, under the kind `note`, with its path as its id. A **per-course**
|
|
refresh leaves them alone — notes belong to the account, not to a course — and
|
|
the store carries the previous generation's rows forward, so a per-course crawl
|
|
never looks like the notes were deleted.
|
|
|
|
Notes are diffed by content, not by modification time, so a sync tool that
|
|
rewrites a file byte-for-byte does not show up in `what_changed` as an edit.
|