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>
358 lines
15 KiB
Markdown
358 lines
15 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, and a small web app to write
|
||
them in.
|
||
|
||
## One note per school day
|
||
|
||
```
|
||
NOTES_DIR/
|
||
2026/
|
||
2026-09-18.md ← Freitag, one heading per lesson
|
||
2026-09-21.md
|
||
Deutsch/
|
||
2026-09-15 Erörterung.md ← a single-subject note, e.g. from the import
|
||
```
|
||
|
||
A day note looks like this, and the shape is load-bearing:
|
||
|
||
```markdown
|
||
---
|
||
title: Freitag, 18.09.2026
|
||
date: 2026-09-18
|
||
source: notes-page
|
||
---
|
||
|
||
## 1. Deutsch — 08:00–08:45 · MEI · R 204
|
||
|
||
Dreischritt: These, Argument mit Beleg, Fazit.
|
||
|
||
### Aufbau
|
||
|
||
- Gegenargument nicht vergessen — kam letztes Jahr in der Arbeit dran
|
||
|
||
## 2. LF07 — 08:50–09:35 · Sb · R 108
|
||
|
||
| Präfix | Nutzbare Adressen |
|
||
|---|---|
|
||
| /24 | 254 |
|
||
```
|
||
|
||
**Each `##` is indexed as its own lesson.** A search for *Gegenargument* answers
|
||
"my own note, Deutsch, 18.09.2026", not "my own note, Friday"; `list_notes
|
||
subject=Deutsch` finds this day even though the note's frontmatter names no
|
||
subject; and `what_changed` reports the lesson that changed rather than the
|
||
whole day. Prose, lists, tables and `###` subheadings all live inside their
|
||
lesson.
|
||
|
||
Nothing forces the shape. A note with no `##` headings — an imported Apple Note,
|
||
a page of revision — is indexed whole, as one piece of prose.
|
||
|
||
## The app
|
||
|
||
`/app` is a small web app: a login, the day's notes, and a settings page. It is
|
||
served only when `WEB_PASSWORD` is set.
|
||
|
||
```
|
||
https://mcp.example.org/app/
|
||
```
|
||
|
||
On a phone it is worth adding to the home screen — it has a manifest and opens
|
||
standalone, which is the difference between "a page I have to find" and "the
|
||
thing I open in a free period".
|
||
|
||
**Notizen** is one screen in two panes: your notes on the left, the open one on
|
||
the right. Side by side where there is room; on a phone the list comes first and
|
||
a note pushes over it, with `‹ Notizen` to come back.
|
||
|
||
The list is every note, newest first — the day, the lessons it covers, and the
|
||
first thing written in it:
|
||
|
||
```
|
||
Freitag, 18.09.2026
|
||
Deutsch · LF07
|
||
Dreischritt: These, Argument mit Beleg, Fazit.
|
||
```
|
||
|
||
**+** opens today, whether or not it has a note yet — the one thing a list of
|
||
notes cannot show you, because an empty day is not a note. `‹ ›` and the date
|
||
field move between days from there.
|
||
|
||
- Opening a day with no note yet **fills in that day's lessons from WebUntis** —
|
||
numbered, with times, teacher and room, cancellations left out and
|
||
substitutions marked. That is the whole reason the app is day-shaped: the one
|
||
thing WebUntis knows and you should not have to retype.
|
||
- **Stunden ergänzen** appears when the timetable has a lesson your note does
|
||
not — a day you started writing before it ended, or a timetable that changed
|
||
after you started. It appends what is missing and never touches what you wrote.
|
||
- It **saves as you type** (a couple of seconds after you stop), when the app
|
||
goes to the background, and when the phone locks.
|
||
- Every keystroke also goes to the browser's local storage. If the connection
|
||
drops mid-lesson you keep writing, and the next time the app loads that day it
|
||
offers the version this device has. **A note taken in a lesson cannot be
|
||
retaken**, which is the reasoning behind all of this.
|
||
- If the note changed elsewhere since you opened it — the laptop, a sync tool,
|
||
`add_note` — the save is refused and you are asked which version wins. It
|
||
never silently overwrites.
|
||
|
||
### Writing, without typing Markdown
|
||
|
||
The editor shows the note **formatted** — headings as headings, bold as bold,
|
||
tables as tables — and the toolbar above it writes the Markdown. Nobody types
|
||
`##` or `**` during a lesson.
|
||
|
||
| Button | What it writes |
|
||
| --- | --- |
|
||
| `H2` | a lesson heading — the one that makes the lesson separately searchable |
|
||
| `H3` | a subheading inside a lesson |
|
||
| `F` `K` `S` | **fett**, _kursiv_, ~~durchgestrichen~~ (`Strg`/`Cmd` + B, I) |
|
||
| `<>` | inline code (`Strg`/`Cmd` + E) |
|
||
| `• —` `1. —` | bullet and numbered lists; nest them with Tab |
|
||
| `☐` | a box to tick off |
|
||
| `❝` | a quote — the teacher's exact wording |
|
||
| `🔗` `▦` | a link (`Strg`/`Cmd` + K) and a table |
|
||
| `MD` | the Markdown itself |
|
||
|
||
`Enter` starts a new paragraph, `Shift+Enter` a new line in the same one. In a
|
||
table, `Tab` walks the cells and a `Tab` out of the last one adds a row;
|
||
`Ctrl`/`Cmd`+`Enter` opens a paragraph after whatever block you are in.
|
||
|
||
A paste from a web page, a PDF or Apple Notes keeps its structure and loses its
|
||
fonts, colours and anything else that is not in the list above — pasted HTML is
|
||
converted to Markdown before it reaches the page, which is what keeps a copied
|
||
page from bringing its script along. **A copy from Apple Notes arrives wrapped
|
||
in one span carrying the computed style of everything in it**, `font-weight:
|
||
700` included; that wrapper is a container, not emphasis, and the blocks inside
|
||
it are blocks. Reading it the other way made a whole pasted note bold and
|
||
flattened every line into one paragraph, which is the failure
|
||
`test/app-markdown.test.ts` now pins down.
|
||
|
||
**The file is still Markdown.** `MD` shows it and lets you edit it directly,
|
||
which is the way to write something the toolbar has no button for. There is no
|
||
underline, because Markdown cannot store one — `F` or `K` instead.
|
||
|
||
Opening a note may tidy it once: `*so*` becomes `_so_`, a table typed unevenly
|
||
lines up. Nothing is rewritten until you actually change something, and a note
|
||
whose formatting the view cannot hold unchanged **opens as Markdown** and says
|
||
so rather than being quietly reduced. `test/app-markdown.test.ts` is what holds
|
||
that promise up: every construct in this document goes in and comes back out
|
||
unchanged.
|
||
|
||
### Searching, in the list
|
||
|
||
The box above the list searches your own notes and nothing else, and the results
|
||
*are* the list — searching is a way of finding a note, not a separate place to
|
||
be. Emptying the box brings the whole list back.
|
||
|
||
Every word has to appear, in any order, ignoring case and accents; there is no
|
||
stemming, so *Argument* does not find *Argumente*.
|
||
|
||
A result names the **lesson**, not the day — `1. LF10 — 08:00–08:45` with the
|
||
date under it and the matched words marked — because a day note holds five or
|
||
six lessons and "Freitag" says nothing about which one matched. Opening a hit
|
||
opens that day **at that lesson**. A note that is not a school day, such as one
|
||
from the Apple Notes import, opens read-only: the editor is day-shaped and those
|
||
notes have no day.
|
||
|
||
It reads the **files**, not the index. That is the point: notes reach the
|
||
Postgres index only on a full crawl, so a lesson written this morning would not
|
||
be there, and "what did I write this week" is most of what anyone searches their
|
||
own notes for. A few hundred small files answer instantly, and it keeps working
|
||
while Postgres is down — the same reason `list_notes` reads disk.
|
||
|
||
The `search` tool in Claude is the other half: it spans the Schulcloud material
|
||
and the WebUntis class register as well, at the cost of being only as fresh as
|
||
the last crawl.
|
||
|
||
**Einstellungen** holds the Schulcloud token: how long it has left, and the box
|
||
to paste a fresh `jwt` cookie into when it expires (the same thing `schulcloud
|
||
token set` and the older `/token` page do). It also shows the index's state and
|
||
the notes directory, and has the logout button.
|
||
|
||
### The login
|
||
|
||
`WEB_PASSWORD` is the app's password — at least 12 characters, and a passphrase
|
||
of three or four words is the right shape. It is hashed with scrypt at startup;
|
||
the plain value is never stored, compared or logged.
|
||
|
||
Logging in sets an `HttpOnly`, `SameSite=Strict` session cookie that lasts 30
|
||
days, so a lesson never starts with a login screen. The cookie opens `/api` —
|
||
it *is* the user — but not `/mcp`, which no browser needs. Changing
|
||
`WEB_PASSWORD` invalidates every session, because the signing key is derived
|
||
from it. Failed logins are rate-limited per address; a password is guessable in
|
||
a way a 32-character token is not, and this endpoint is on the internet.
|
||
|
||
If `WEB_PASSWORD` is unset the app is not served at all — the same rule the
|
||
`untis_*` and note tools follow. A login screen that no password can open is
|
||
worse than no page, because it looks like a way in.
|
||
|
||
## The other four ways to write a note
|
||
|
||
The app is not privileged; it writes the same files as everything else.
|
||
|
||
```bash
|
||
# the command line, text piped in
|
||
pbpaste | schulcloud note add --title "Subnetting" --subject LF07
|
||
|
||
# an editor, or anything else that writes files
|
||
$EDITOR "$NOTES_DIR/2026/2026-09-18.md"
|
||
|
||
# Claude, during or after the lesson
|
||
# "halt fest: Gegenargument nicht vergessen, kam letztes Jahr dran"
|
||
# → add_note, subject Deutsch, today's date
|
||
|
||
# a folder you already sync — see below
|
||
```
|
||
|
||
`add_note` and `schulcloud note add` take `append`, which adds to the note
|
||
already written **for that subject and that day** rather than starting a second
|
||
one — so "note this down too" mid-lesson lands in the note that is already open,
|
||
whatever its title.
|
||
|
||
## What may write, and where
|
||
|
||
**The notes directory is the only thing this server writes to.** Not Schulcloud,
|
||
not WebUntis — those stay read-only, strictly, and that has not changed.
|
||
|
||
Every path component goes through `safeComponent` and the result through
|
||
`resolveWithin`, the same two functions that stop a hostile Schulcloud filename
|
||
escaping the file mirror, so a note titled `../../.ssh/authorized_keys` becomes
|
||
a filename. `NOTES_READONLY=1` refuses writes entirely — right when the notes
|
||
are synced in from somewhere else and should have exactly one writer. The app
|
||
then still reads them, and says so rather than failing on save.
|
||
|
||
## 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
|
||
osascript -l JavaScript scripts/export-apple-notes.js --folder Schule > schule.ndjson
|
||
```
|
||
|
||
The first run raises a macOS permission dialog ("Terminal wants access to
|
||
Notes"); without it every note comes back empty.
|
||
|
||
**`--folder Schule` exports one folder and everything under it.** Folder names
|
||
are matched whole, not as a fragment, and the path is recorded relative to the
|
||
one you named — so `Schule/Deutsch` arrives as the subject `Deutsch`, and a note
|
||
sitting loose in `Schule` arrives with no subject rather than one called
|
||
"Schule". Reading a note's body is the expensive part, so filtering here rather
|
||
than afterwards is also what makes a large library finish.
|
||
|
||
The records go to standard output and the progress line to standard error, so
|
||
`> notes.ndjson` gets exactly the notes. Check it took: `wc -l notes.ndjson`
|
||
should be the number the script reported.
|
||
|
||
Then look at what it would do, and do it:
|
||
|
||
```bash
|
||
schulcloud note import notes.ndjson --dry-run
|
||
schulcloud note import notes.ndjson # into the server
|
||
schulcloud note import notes.ndjson --out ~/Notizen # or to a local folder first
|
||
```
|
||
|
||
```
|
||
would import: 2026-09-15 · Deutsch · Erörterung
|
||
Would import 84 of 91 note(s), skipped 5 with no text, 2 unreadable in Notes.
|
||
```
|
||
|
||
What it 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.
|
||
|
||
Imported notes arrive as single-subject notes, not day notes, which is the
|
||
shape they were written in. Both kinds coexist.
|
||
|
||
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.
|
||
|
||
## Reading them
|
||
|
||
| Tool | Answers |
|
||
|---|---|
|
||
| `list_notes` | "what did I write down in Deutsch before the test" — by subject or date, lesson headings included |
|
||
| `get_note` | one note in full, by the path everything else prints |
|
||
| `search` | notes by their contents, per lesson, next to board text and the inside of PDFs |
|
||
| `what_changed` | lessons and 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.
|
||
|
||
## Deploying it
|
||
|
||
Adding this to a server that is already running — the password, where the notes
|
||
live, backups and rollback — is [DEPLOY-NOTES.md](DEPLOY-NOTES.md).
|
||
|
||
## Keeping them somewhere you already sync
|
||
|
||
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`. New files are picked up by the next crawl; nothing has to be
|
||
told about them, and the app edits the same files.
|
||
|
||
Frontmatter is a small YAML subset — scalars and lists, in either the inline
|
||
`[a, b]` or the indented `- item` form editors write — so an Obsidian vault
|
||
round-trips. Unknown keys are kept and ignored.
|
||
|
||
What the server reads out of a note:
|
||
|
||
| 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 — except a year, which is a filing scheme |
|
||
| `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:`.
|
||
|
||
**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.
|
||
|
||
## What the crawl does with them
|
||
|
||
A **full** crawl reads the directory and indexes every note — one entry per
|
||
lesson for a day note, one for the whole note otherwise, under the kind `note`.
|
||
A **per-course** refresh leaves them alone, 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.
|