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)
# ---------------------------------------------------------------------------

View File

@@ -8,8 +8,14 @@ Read-only access to a Schulcloud (HPI Schul-Cloud / Schulcloud-Verbund-Software)
account: courses, column boards, lessons, tasks, files with text extraction, and
a Postgres-backed full-text index — plus the **timetable from WebUntis**, which
is a separate system and the only place this school publishes when a lesson
happens, or that it was cancelled. TypeScript, Node 22+,
`@modelcontextprotocol/sdk`.
happens, or that it was cancelled, and **the user's own lesson notes**, a
directory of Markdown files that is the only record of what was actually said in
the room. TypeScript, Node 22+, `@modelcontextprotocol/sdk`.
Three sources, and the distinction matters in every tool description: Schulcloud
has the material, WebUntis has the schedule and the class register, the notes
have what the teacher stressed. An answer that silently merges them is worse
than one that says which said what.
Three entry points over one core:
- `src/bin/http.ts` — Streamable HTTP + `/api`, the deployed form, behind Caddy on a Pi.
@@ -47,10 +53,18 @@ index.
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,
`src/mcp/` or `src/http/` — the unit tests cover only pure functions.
Run smoke **both ways**: with `DATABASE_URL` set (93 checks, index-backed) and
without (91 checks, live-only); without a WebUntis key both drop by 9, and the
run then asserts the `untis_*` tools are *not* offered. The degradation paths are
supported modes, not fallbacks nobody exercises. Every Schulcloud check fails with 401 when the live
Run smoke **both ways**: with `DATABASE_URL` set (index-backed) and without
(live-only). Without a WebUntis key the run asserts the `untis_*` tools are
*not* offered instead of exercising them, and the same holds for `NOTES_DIR`
except that the smoke sets its own throwaway one, so the note tools are always
exercised and can never touch real notes. The degradation paths are supported
modes, not fallbacks nobody exercises.
The check counts are a tripwire, so re-measure them rather than trusting this
line after a change: **88/89 against the local instance** on 2026-09-18 (the one
failure is the H5P service, which that instance does not run). The live counts
are stale — they were last taken before the notes and class-register work, and
could not be retaken because the live session had lapsed. Every Schulcloud check fails with 401 when the live
session has lapsed — check the container's keepalive log before suspecting code.
Store tests need a database and skip without one:
@@ -94,6 +108,16 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
from Profil → Freigaben, so there is no session and no keepalive on this
side. Resolves the payload's element ids to names and returns every day in
a range, empty ones included. See Invariants for why the allowlist is there.
- **`notes.ts`** — the user's own lesson notes as a directory of Markdown
files with a small frontmatter dialect. The files are the truth and the
index is a view of them, so `list_notes`/`get_note` read disk and answer
before the first crawl and while Postgres is down. **The one thing anything
here writes** — see Invariants. `docs/NOTES.md` is the guide.
- **`untis-history.ts`** — the class register read backwards, which is what
puts "what did we actually cover" into the search index. Its whole reason
for existing is one API property: `getLessonTopic2017` answers per *series*,
so a term costs one call per lesson series rather than one per period — see
API gotchas.
- `h5p.ts` — the quizzes on a board. One GET per element, parsed into
questions and answers; board assembly attaches it like a pad, the crawl
indexes its text, and `get_h5p` prints it.
@@ -121,6 +145,15 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
- **`mcp/tools/untis.ts`** — the `untis_*` tools, registered **only** when
`UNTIS_*` is configured: a tool that can only fail is worse than a missing
one. `readTimetable` is shared with the prompt, the way `readCourse` is.
`untis_lesson_topics` takes **either** a `periodId` (one series) **or** a
`subject` (a whole term, via `untis-history.ts`); neither and both are both
refused, because guessing which was meant is worse than asking.
- **`mcp/tools/notes.ts`** — `list_notes`, `get_note` and `add_note`, registered
**only** when `NOTES_DIR` is set, by the same rule as the `untis_*` tools.
- **`mcp/prompts.ts` takes a `Sources` flag** (`{ notes, untis }`) so a prompt
never tells Claude to call a tool this deployment does not register. A prompt
built with no sources names none of them — that is the default, and the safe
one.
- **`mcp/resources.ts`, `mcp/prompts.ts`** — courses and rooms as resources a
person attaches, carrying exactly `readCourse`/`readRoom`, the functions behind
`get_course`/`get_room`; and three prompts, the third being
@@ -145,6 +178,17 @@ neither the bearer nor the `jwt` cookie may go with it. `refresh_index` and `POS
own index and mirror, and `PUT /api/token` only to the server's own token — every
upstream call they make is still a GET.
**The notes directory is the only thing anything here writes to.** That is not
an exception to the invariant above — it is a different store: the user's own
files, never Schulcloud and never WebUntis. It is bounded by the same two
functions as the file mirror, and for the same reason: `add_note`'s title and
subject arrive from a tool call, become path components through `safeComponent`,
and the result is checked by `resolveWithin`, so a note titled
`../../.ssh/authorized_keys` becomes a filename. `NOTES_READONLY` refuses writes
entirely. Do not widen this to anything outside `NOTES_DIR`, and do not take it
as precedent for a Schulcloud write tool — that decision is the one above, and
it has not changed.
**WebUntis is read-only by allowlist, not by verb.** Its API is JSON-RPC, so
every call is a POST, reads included — "GET only" cannot carry over. Instead
`core/untis.ts` holds `READ_METHODS` and `assertReadMethod` refuses anything
@@ -354,6 +398,12 @@ cost the most:
- **Announced tests live in `text.info`**, not in the exam module — this school
does not use it, so `getExams2017` is always empty. That field is the most
valuable thing in the payload.
- **`getLessonTopic2017` answers per *series*, not per period.** Its
`previousTopics` are the lessons *before* the period you name, each carrying
its own `periodId`, so a term is reconstructed by taking the distinct
`lessonId`s in a range, asking about the **latest** period of each, and
merging back by id — a few dozen calls for a school year. Asking about the
earliest period of a series reaches none of its history. `core/untis-history.ts`.
- **A day with no lessons is not a holiday.** Vocational school weeks spent at
the company simply have no periods, and `holidays` says nothing about them.
Do not report "Ferien" for them; say there are no lessons.
@@ -383,7 +433,8 @@ cost the most:
3. Format output as Markdown, keeping ids visible for follow-up calls.
4. If it reads the index, handle `context.store === undefined` with a message
saying what is unavailable and what still works. A `untis_*` tool instead
registers only when `context.untis` exists.
registers only when `context.untis` exists, and a note tool only when
`config.notesDir` is set.
5. Add a check to `scripts/smoke.mjs` and run `npm run smoke` both ways.
## Resources and prompts
@@ -410,8 +461,9 @@ bundle (2.1.272), not its docs:
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`, and optionally
`MCP_CONNECTOR_TOKEN` or `MCP_PATH_SECRET`, plus the four `UNTIS_*` values (all
four or none — a half-filled block is a paste that went wrong, so it throws);
docker-compose sets `STATE_DIR`. See `.env.example` for the
four or none — a half-filled block is a paste that went wrong, so it throws),
`NOTES_DIR` and `UNTIS_HISTORY_DAYS`; docker-compose sets `STATE_DIR` and
`NOTES_DIR`. See `.env.example` for the
full set and `docs/AUTH.md` for refreshing the JWT — `schulcloud token set`,
no restart. `npm run probe` and `schulcloud token` report the clocks: days until
hard expiry and the session budget.

View File

@@ -2,8 +2,9 @@
Read-only access to a [Schulcloud](https://github.com/hpi-schul-cloud) account —
courses, boards, lessons, tasks and files — plus the timetable from
[WebUntis](https://www.untis.at/), for **Claude**, via MCP, and for **you**, via
a CLI that mirrors your coursework to disk.
[WebUntis](https://www.untis.at/) and **the notes you take in class**, for
**Claude**, via MCP, and for **you**, via a CLI that mirrors your coursework to
disk.
Both are front ends over one core library and one live Schulcloud session, kept
alive on a Pi.
@@ -19,8 +20,10 @@ instance, not inferred from the upstream source.
> *"Summarise the routing lesson from the LF10 course."*
> *"What do I have tomorrow, and has anything been cancelled?"*
> *"Quiz me on the DIN 5008 exercise from the DK room."*
> *"What did we actually cover in Deutsch before the test — and what did I write down?"*
Twenty-eight tools, all read-only:
Thirty-one tools. Everything that touches Schulcloud and WebUntis is read-only;
`add_note` writes a file in your own notes directory and nowhere else.
| | |
|---|---|
@@ -51,6 +54,9 @@ Twenty-eight tools, all read-only:
| `api_get` | GET-only escape hatch for uncovered API surface |
| `untis_timetable` | the school day from **WebUntis**: lessons, Entfall, Vertretung, room changes, period notes |
| `untis_homework` | homework from WebUntis' class register — a separate list from Schulcloud's tasks |
| `list_notes` | your own lesson notes — what you wrote down, by subject or date |
| `get_note` | one of your notes in full |
| `add_note` | write a note down during or after a lesson |
| `untis_lesson_topics` | what previous lessons of a subject actually covered ("Unterrichtsinhalt") |
`download_file` extracts text from **PDF, DOCX, XLSX, PPTX and OpenDocument**
@@ -63,6 +69,12 @@ MCP resource (`schulcloud://courses/<id>`, `schulcloud://rooms/<id>`) holding
the same overview `get_course` and `get_room` return. In Claude Code, type `@`
and part of the course name.
**Your own notes are the third source.** Schulcloud has the material and
WebUntis has the schedule; neither has what the teacher actually stressed. Point
`NOTES_DIR` at a directory of Markdown files and `search`, `what_changed` and
all three prompts read it alongside everything else — including a migration path
out of Apple Notes. See [docs/NOTES.md](docs/NOTES.md).
**Three ready-made prompts**, in German because the school is:
| | |
@@ -87,6 +99,8 @@ schulcloud sync # mirror coursework to ~/Schulcloud
schulcloud refresh --course <id>
schulcloud fs tree /courses # browse the file manager ("Dateien")
schulcloud fs get "/courses/<course>/<folder>"
schulcloud note ls --subject Deutsch
pbpaste | schulcloud note add --title Subnetting --subject LF07
schulcloud token set # the monthly chore: hand the Pi a fresh Schulcloud token
```
@@ -186,14 +200,15 @@ bypass, "what's new since…" — are sketched with their trade-offs in
```
src/
core/ client, types, board assembly, crawler, extraction, paths, WebUntis
core/ client, types, board assembly, crawler, extraction, paths,
WebUntis and its class register, your own notes
store/ Postgres: crawl generations, diffs, full-text search
indexer/ crawl → persist → mirror bytes → extract text → index
mcp/ MCP server, tools, resources and prompts
http/ express app, bearer auth, /api for the CLI
cli/ CLI config, API client, sync engine
bin/ http, stdio and cli entry points
docs/ API findings, auth, deployment, CLI, roadmap
docs/ API findings, auth, deployment, CLI, notes, roadmap
deploy/ Caddyfile snippet, the Pi's compose file
scripts/ probe, smoke, session diagnostics
vendor/ upstream clones, git-ignored, for reference only

View File

@@ -39,15 +39,21 @@ services:
BIND_HOST: 0.0.0.0
MIRROR_DIR: /data/mirror
STATE_DIR: /data/state
NOTES_DIR: /data/notes
depends_on:
postgres:
condition: service_healthy
volumes:
# The mirror and a replaced Schulcloud token are the only things this
# server writes; everything else stays read-only, so each gets its own
# volume rather than loosening read_only.
# The mirror, a replaced Schulcloud token and the user's own notes are the
# only things this server writes; everything else stays read-only, so each
# gets its own volume rather than loosening read_only.
- mirror:/data/mirror
- state:/data/state
# The notes. Swap this line for a bind mount to keep them in a folder the
# user already syncs (Syncthing, Nextcloud, an Obsidian vault) and write
# them from a phone instead of through the API — see docs/NOTES.md:
# - /home/pi/Notizen:/data/notes
- notes:/data/notes
# No ports are published to the host: Caddy reaches the container over the
# shared Docker network, so the only way in from the internet is through
# Caddy's TLS and this server's bearer check.
@@ -73,6 +79,7 @@ volumes:
pgdata:
mirror:
state:
notes:
networks:
backend:

View File

@@ -390,6 +390,15 @@ uses is the API the Untis Mobile app uses, verified against
`previousTopics`: what the earlier lessons of that series actually covered,
from the class register. A `periodIds` array is rejected as "period 0 not
found".
- **It answers per *series*, so one call covers a term.** The entries come
back with their own `periodId` and date, which is what lets a range of
lessons be reconstructed from a handful of calls rather than one per period:
take the distinct `lessonId`s in the range, ask about the **latest**
`periodId` of each, and merge the answers back onto the periods by id.
Asking about the earliest period of a series instead reaches none of its
history, because "previous" is relative to the period given.
`core/untis-history.ts` is that walk, and it is what puts the class register
into the search index.
- **The exam module is unused at this school**, so `getExams2017` is empty and
`period.exam` is null. Announced tests are typed into the period's **info
text** instead ("LF10: Leistungskontrolle agile Softwareentwicklung …"), which

View File

@@ -39,6 +39,7 @@ schulcloud ls [--course <id>] [--long]
schulcloud get <fileId> [--out <path>]
schulcloud sync [--dry-run] [--full] [--prune] [--dir <path>] [--jobs <n>]
schulcloud refresh [--course <id>] [--force]
schulcloud note ... the notes you take in class — see below
```
`ls --long` prints file ids, which is what `get` takes.
@@ -114,6 +115,39 @@ while.
`Persönliche Dateien/…`, `Team-Dateien/<team>/…` and `Geteilte Dateien/`, once
the server's index includes them (`INDEX_FILE_MANAGER`, on by default).
### Your own lesson notes (`note`)
The notes you take in class, which Claude reads as context. The full story is in
[NOTES.md](NOTES.md); these are the commands.
```
schulcloud note ls [--subject <name>] [--since <date>] [--until <date>] [--long]
schulcloud note show <path>
schulcloud note add --title <title> [--subject <name>] [--date <date>]
[--tags a,b] [--append] text on stdin, or --text
schulcloud note import <export.ndjson> [--subject <name>] [--out <dir>] [--dry-run]
```
```console
$ pbpaste | schulcloud note add --title "Subnetting" --subject LF07
Saved LF07/2026-09-16 Subnetting.md
$ schulcloud note ls --subject Deutsch --since 2026-09-01
2026-09-22 [Deutsch] Sprachanalyse
2026-09-15 [Deutsch] Erörterung — Aufbau
$ schulcloud note show "Deutsch/2026-09-15 Erörterung.md"
```
`note add` reads the note from stdin, so it comes just as easily from a
clipboard, an editor or another command; `--append` adds to the note already
written for that subject and day rather than starting a second one.
`note import` takes the file `scripts/export-apple-notes.js` writes on a Mac.
`--dry-run` shows what it would do and `--out <dir>` writes the Markdown
locally instead of sending it to the server — the only note command that needs
no server at all.
## How sync works
It is a **one-way mirror, not a two-way sync**, and that follows from the data

205
docs/NOTES.md Normal file
View File

@@ -0,0 +1,205 @@
# 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.

View File

@@ -94,6 +94,38 @@ Genuinely unavailable, not merely uncovered:
- **Numeric grades** — the API's `grade` was null on every graded submission
here, so that path stays unverified against real data.
## 4b. A third source: the user's own notes — BUILT
Measured after a term of use: 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 is
only ever in what the student wrote down, and it was sitting in Apple Notes
where nothing could read it.
Built as a directory of Markdown files (`NOTES_DIR`), read by `list_notes` /
`get_note`, indexed as `kind: 'note'`, searched by both the index and the live
path, diffed by `what_changed`, and consulted by all three German prompts.
`add_note` writes one — the only write in this server, and bounded to that
directory by the same `safeComponent`/`resolveWithin` pair that guards the file
mirror. `scripts/export-apple-notes.js` plus `schulcloud note import` is the
migration path out of Notes.app, which has no export of its own. See
`docs/NOTES.md`.
**Files rather than a table**, deliberately: they have to be writable from a
classroom, readable when Postgres is down, and outlive this project.
## 4c. The class register into the index — BUILT
`untis_lesson_topics` could already answer "where did we get to" for one series
from one period id. What it could not do was answer "what have we done in
Deutsch this term", and none of it was searchable.
Both fall out of one API property: `getLessonTopic2017` answers per *series*, so
a term costs one call per lesson series (`core/untis-history.ts`). The tool now
takes a subject as well as a period id, and `UNTIS_HISTORY_DAYS` of register —
topics, the notes teachers leave on a period, announced tests, homework — go
into the index as `kind: 'untis'`.
## 5. Still open
- **Video/audio transcription** — this account has 5 MP4s and a WebM that are

View File

@@ -37,6 +37,9 @@ export TSC_JWT_COOKIE=$token
export MCP_AUTH_TOKEN=local-instance-token
export DATABASE_URL=postgresql://schulcloud:schulcloud@127.0.0.1:55432/schulcloud_local
export MIRROR_DIR=$ROOT/tmp/mirror-local
# Notes are pinned for the same reason as the mirror: they are the one thing
# this server writes, and a local run has no business writing into the real ones.
export NOTES_DIR=$ROOT/tmp/notes-local
export INDEX_PERSONAL_FILES=true
# WebUntis off for a local run: this instance has no timetable, and the key in
# the repo's .env belongs to the real school — a fixture run has no business

140
scripts/export-apple-notes.js Executable file
View File

@@ -0,0 +1,140 @@
#!/usr/bin/env osascript -l JavaScript
/**
* Exports Apple Notes to one JSON object per line, on stdout.
*
* Run this **on the Mac that has the notes**:
*
* osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson
* osascript -l JavaScript scripts/export-apple-notes.js --folder Deutsch > deutsch.ndjson
*
* then hand the file to `schulcloud note import notes.ndjson`.
*
* Why this exists at all: Notes.app has no export. Its database is a Core Data
* store whose bodies are compressed protobuf, and the iCloud copy is encrypted,
* so scripting the app is not the clumsy route to the notes — it is the only
* one. The first run raises a macOS permission dialog ("Terminal wants access
* to Notes"); without it every note comes back empty.
*
* JXA and not AppleScript because it can serialise JSON, and because reading
* properties one note at a time is what keeps a locked note from aborting the
* run rather than a language preference.
*/
ObjC.import('stdlib');
function run(argv) {
const options = parseArguments(argv);
const notes = Application('Notes');
notes.includeStandardAdditions = true;
let items;
try {
items = notes.notes();
} catch (error) {
return fail(
'Could not read Notes. Grant the terminal access under System Settings → Privacy & Security → ' +
'Automation, then run this again.\n' + error,
);
}
let written = 0;
let skipped = 0;
for (let i = 0; i < items.length; i++) {
const note = items[i];
const record = readNote(note);
if (!record) {
skipped++;
continue;
}
if (options.folder && (record.folder || '').toLowerCase().indexOf(options.folder.toLowerCase()) === -1) continue;
// One object per line, so a huge export streams and a bad note costs one line.
console.log(JSON.stringify(record));
written++;
}
// stderr, so it never lands in the file being redirected.
log('Exported ' + written + ' note(s)' + (skipped > 0 ? ', skipped ' + skipped + ' unreadable' : '') + '.');
return '';
}
function readNote(note) {
try {
// Read the body first: it is the property a locked note refuses, and
// there is no point building a record we cannot fill.
const body = note.body();
return {
id: safe(function () { return note.id(); }, ''),
name: safe(function () { return note.name(); }, ''),
body: body || '',
folder: safe(function () { return folderPath(note.container()); }, ''),
created: safe(function () { return iso(note.creationDate()); }, ''),
modified: safe(function () { return iso(note.modificationDate()); }, ''),
};
} catch (error) {
// A locked note, or one iCloud has not downloaded. Reported, not dropped
// silently: a missing lesson is worse than a line in the file.
return {
id: safe(function () { return note.id(); }, ''),
name: safe(function () { return note.name(); }, '(unreadable)'),
body: '',
error: String(error),
};
}
}
/** "Schule/Deutsch" — the import takes the last segment as the subject. */
function folderPath(container) {
const parts = [];
let current = container;
for (let depth = 0; current && depth < 8; depth++) {
const name = safe(function () { return current.name(); }, '');
if (!name) break;
parts.unshift(name);
current = safe(function () { return current.container(); }, null);
}
return parts.join('/');
}
/**
* A Date as local ISO, not UTC.
*
* `toISOString` would shift a note taken at 08:30 in Erfurt back to the
* previous day for anything written before 01:00 or 02:00, and the date is
* the whole point of the record.
*/
function iso(date) {
if (!date) return '';
const pad = function (value) { return String(value).padStart(2, '0'); };
return (
date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) +
'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds())
);
}
function safe(read, fallback) {
try {
const value = read();
return value === undefined || value === null ? fallback : value;
} catch (error) {
return fallback;
}
}
function parseArguments(argv) {
const options = { folder: '' };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--folder' && argv[i + 1]) options.folder = argv[++i];
}
return options;
}
function log(message) {
$.NSFileHandle.fileHandleWithStandardError.writeData(
$.NSString.alloc.initWithUTF8String(message + '\n').dataUsingEncoding($.NSUTF8StringEncoding),
);
}
function fail(message) {
log(message);
$.exit(1);
}

View File

@@ -25,6 +25,11 @@ process.env.MCP_CONNECTOR_TOKEN = CONNECTOR_TOKEN;
// A state directory of its own, so the run can neither read nor leave a saved token.
const STATE_DIR = await mkdtemp(join(tmpdir(), 'schulcloud-smoke-state-'));
process.env.STATE_DIR = STATE_DIR;
// And a notes directory of its own. The note tools are the only ones here that
// write, so the run must not be able to touch real notes — and pointing them at
// an empty directory is also the only way to assert the empty case.
const NOTES_DIR = await mkdtemp(join(tmpdir(), 'schulcloud-smoke-notes-'));
process.env.NOTES_DIR = NOTES_DIR;
// The app is bound by this script on an ephemeral port, so config.port is unused.
const config = loadConfig();
@@ -480,6 +485,73 @@ if (taskId) {
check('list_submissions unscoped', !all.isError, all.text.split('\n')[0]);
}
console.log('\n== own notes ==');
// The user's own lesson notes: the one store here that is neither Schulcloud's
// nor WebUntis', and the one thing this server can write. Every check runs
// against the throwaway NOTES_DIR above.
{
const noteTools = names.filter((name) => ['list_notes', 'get_note', 'add_note'].includes(name));
check('the note tools are offered when NOTES_DIR is set', noteTools.length === 3, noteTools.join(', ') || 'none');
const empty = await call('list_notes');
check(
'an empty notes directory is explained, not reported as a failure',
!empty.isError && /no notes yet/i.test(empty.text),
empty.text.split('\n')[0],
);
const added = await call('add_note', {
title: 'Erörterung',
text: 'Dreischritt: These, Argument, Fazit. Gegenargument nicht vergessen.',
subject: 'Deutsch',
date: '2026-09-15',
tags: ['klausur'],
});
check('add_note saves a note', !added.isError && /Deutsch\/2026-09-15 Erörterung\.md/.test(added.text), added.text.split('\n')[0]);
const listed = await call('list_notes', { subject: 'deut' });
check('list_notes finds it by a fragment of the subject', !listed.isError && /Erörterung/.test(listed.text), listed.text.split('\n')[0]);
const one = await call('get_note', { path: 'Deutsch/2026-09-15 Erörterung.md' });
check('get_note returns the note in full', !one.isError && /Gegenargument/.test(one.text), one.text.split('\n')[0]);
const appended = await call('add_note', {
title: 'Nachtrag',
text: 'Beispiel: Handyverbot an Schulen.',
subject: 'Deutsch',
date: '2026-09-15',
append: true,
});
const afterAppend = await call('get_note', { path: 'Deutsch/2026-09-15 Erörterung.md' });
check(
'append adds to the same note rather than starting a second one',
!appended.isError && /Handyverbot/.test(afterAppend.text) && /Gegenargument/.test(afterAppend.text),
appended.text.split('\n')[0],
);
const missing = await call('get_note', { path: 'Deutsch/gibt-es-nicht.md' });
check('a missing note is a tool error naming the path', missing.isError && /no note at/i.test(missing.text), missing.text.split('\n')[0]);
// The title reaches the filesystem, so it is untrusted input at exactly the
// boundary core/paths.ts exists to guard.
const hostile = await call('add_note', { title: '../../../etc/passwd', text: 'x', subject: '../..', date: '2026-09-15' });
// The title is echoed back verbatim — it is the user's own — so the check is
// on the path the note actually landed at, in backticks.
const hostilePath = hostile.text.match(/`([^`]+)`/)?.[1] ?? '';
check(
'a note cannot be written outside the notes directory',
!hostile.isError && hostilePath.length > 0 && !hostilePath.split('/').includes('..'),
hostilePath,
);
const fresh = await call('search', { query: 'Gegenargument', fresh: true, courseId: courseIds[0] });
check(
'a live search reads the notes too, so it agrees with the index',
!fresh.isError && /Gegenargument/.test(fresh.text),
fresh.text.split('\n')[0],
);
}
console.log('\n== index tools ==');
// These degrade gracefully without DATABASE_URL, so assert on either outcome
// rather than requiring a database for the smoke run to be meaningful.
@@ -509,6 +581,13 @@ if (hasIndex) {
!indexed.isError && /refreshed/i.test(indexed.text),
indexed.text.split('\n')[0],
);
// Notes are only picked up by a *full* crawl, and a full crawl walks every
// course and every file-manager folder — minutes, not seconds. Indexing them
// is covered by test/store.test.ts against a real Postgres instead; what the
// smoke checks here is that the kind filter exists and answers.
const byKind = await call('search', { query: 'Gegenargument', kinds: ['note'] });
check('search accepts the note kind', !byKind.isError, byKind.text.split('\n')[0]);
}
console.log('\n== WebUntis ==');
@@ -580,6 +659,26 @@ if (hasUntis) {
check('untis_lesson_topics reads what previous lessons covered', true, 'skipped: no lesson in the window');
}
// The subject form is the one that reconstructs a term without a period id.
const subject = month.text.match(/\*\*([A-Za-zÄÖÜäöü0-9]{2,10})\*\*/)?.[1];
if (subject) {
const bySubject = await call('untis_lesson_topics', { subject, from: '2026-06-01', to: end, limit: 5 });
check(
`untis_lesson_topics reads a whole term by subject ("${subject}")`,
!bySubject.isError && (/Unterricht „/.test(bySubject.text) || /No lessons of|nothing was recorded/.test(bySubject.text)),
bySubject.text.split('\n')[0],
);
} else {
check('untis_lesson_topics reads a whole term by subject', true, 'skipped: no subject in the window');
}
const neither = await call('untis_lesson_topics', {});
check(
'untis_lesson_topics asks for a subject or a period, not neither',
neither.isError && /subject/.test(neither.text),
neither.text.split('\n')[0],
);
const unreal = await call('untis_timetable', { from: '2026-02-30' });
check(
'a date that does not exist is refused rather than rolled over',
@@ -713,6 +812,7 @@ await client.close();
httpServer.close();
await closeServices(services);
await rm(STATE_DIR, { recursive: true, force: true });
await rm(NOTES_DIR, { recursive: true, force: true });
console.log(`\n${results.length - failures}/${results.length} checks passed`);
process.exit(failures === 0 ? 0 : 1);

View File

@@ -9,6 +9,7 @@ import { readHidden, readPiped } from '../cli/prompt.ts';
import { defaultSyncDir, loadCliConfig, saveCliConfig, configPath } from '../cli/config.ts';
import { formatBytes } from '../core/extract.ts';
import { fsFind, fsGet, fsList, fsTree } from '../cli/fs.ts';
import { noteAdd, noteImport, noteList, noteShow } from '../cli/notes.ts';
import { sync, type SyncEvent } from '../cli/sync.ts';
/**
@@ -40,6 +41,17 @@ The file manager ("Dateien") — /my, /courses/<course>, /teams/<team>, /shared:
fs get downloads a file, or a folder with everything below it. Names may contain
"/" and still resolve; any path segment can also be an id from "fs ls --long".
Your own lesson notes — Markdown files the agents read as context:
schulcloud note ls [--subject <name>] [--since <date>] [--until <date>] [--long]
schulcloud note show <path>
schulcloud note add --title <title> [--subject <name>] [--date <date>]
[--tags a,b] [--append] text on stdin, or --text
schulcloud note import <export.ndjson> [--subject <name>] [--out <dir>] [--dry-run]
note import takes the file scripts/export-apple-notes.js writes on a Mac; see
docs/NOTES.md. --out writes the Markdown locally instead of sending it.
--course takes a course or a room id: rooms ("Räume") mirror alongside courses
and their files sit under the room's name.
@@ -74,6 +86,9 @@ async function main(argv: string[]): Promise<number> {
return refresh(flags);
case 'fs':
return fileManager(flags);
case 'note':
case 'notes':
return notes(flags);
case 'token':
return token(flags);
default:
@@ -207,6 +222,87 @@ async function fileManager(flags: Flags): Promise<number> {
}
}
async function notes(flags: Flags): Promise<number> {
const [sub, ...args] = flags._ as string[];
const out = (line: string) => process.stdout.write(`${line}\n`);
// `--out` writes files directly, which is the one note command that needs no
// server: a migration should be runnable and inspectable before anything is
// sent anywhere.
const offlineImport = sub === 'import' && Boolean(flags.out);
const api = offlineImport ? undefined : new ApiClient(await loadCliConfig());
switch (sub) {
case 'ls':
case 'list':
return noteList(
api!,
{
...(flags.subject ? { subject: String(flags.subject) } : {}),
...(flags.since ? { since: String(flags.since) } : {}),
...(flags.until ? { until: String(flags.until) } : {}),
},
Boolean(flags.long),
out,
);
case 'show':
case 'cat':
if (!args[0]) {
process.stderr.write('note show needs a path, e.g.: schulcloud note show "Deutsch/2026-09-15 Erörterung.md"\n');
return 2;
}
return noteShow(api!, args[0], out);
case 'add': {
const title = flags.title ? String(flags.title) : args[0];
if (!title) {
process.stderr.write('note add needs --title.\n');
return 2;
}
// Piped text is the normal way in: it is how a note gets here from an
// editor, a clipboard or another command. Typing it straight in works
// too, but only if we say how it ends.
if (!flags.text && process.stdin.isTTY) {
process.stderr.write('Type the note, then Ctrl-D to save (Ctrl-C to abort):\n');
}
const body = flags.text ? String(flags.text) : await readPiped();
if (!body?.trim()) {
process.stderr.write('note add needs the note text: pass --text, or pipe it in.\n');
return 2;
}
return noteAdd(
api!,
{
title,
text: body,
...(flags.subject ? { subject: String(flags.subject) } : {}),
...(flags.date ? { date: String(flags.date) } : {}),
...(flags.tags ? { tags: String(flags.tags).split(',').map((tag) => tag.trim()).filter(Boolean) } : {}),
append: Boolean(flags.append),
},
out,
);
}
case 'import':
if (!args[0]) {
process.stderr.write('note import needs the export file, e.g.: schulcloud note import notes.ndjson\n');
return 2;
}
return noteImport(
api,
args[0],
{
...(flags.out ? { outDir: resolve(String(flags.out)) } : {}),
...(flags.subject ? { subject: String(flags.subject) } : {}),
dryRun: Boolean(flags['dry-run']),
},
out,
);
default:
process.stderr.write(`Unknown note command "${sub ?? ''}". Use ls, show, add or import.\n\n${USAGE}`);
return 2;
}
}
async function runSync(flags: Flags): Promise<number> {
const config = await loadCliConfig();
const root = flags.dir ? resolve(String(flags.dir)) : config.syncDir;

190
src/cli/apple-notes.ts Normal file
View File

@@ -0,0 +1,190 @@
import { decodeEntities } from '../core/text.ts';
/**
* Turning an Apple Notes export into notes this server can read.
*
* Notes.app stores a note's body as HTML and exposes it through AppleScript,
* which is the only interface it has — there is no file on disk to copy, no
* export format worth the name, and iCloud's copy is encrypted. So
* `scripts/export-apple-notes.js` reads the notes through that interface and
* writes one JSON object per line; this converts them.
*
* Kept out of `core/` because nothing on the server needs it: a migration runs
* once, from the Mac that has the notes, and the server only ever sees the
* Markdown that comes out.
*/
/** One note as `scripts/export-apple-notes.js` writes it. */
export interface AppleNote {
id: string;
name: string;
/** The note's HTML body. */
body: string;
/** The Notes folder it sits in — "Notizen", "Deutsch", "Schule/LF07". */
folder?: string;
/** ISO timestamps from Notes.app. */
created?: string;
modified?: string;
/** Set when Notes refused the body, e.g. a locked note. */
error?: string;
}
export interface ConvertedNote {
title: string;
text: string;
date?: string;
subject?: string;
source: string;
}
/**
* An exported note as a note here.
*
* The creation date becomes the note's date because that is the day of the
* lesson it was taken in — the modification date is whenever it was last
* tidied, which is not a school day at all.
*/
export function convertAppleNote(note: AppleNote, options: { subject?: string } = {}): ConvertedNote {
const body = htmlToMarkdown(note.body);
const title = (note.name || firstLine(body) || 'Notiz').trim();
// Notes repeats the title as the first line of the body; keeping both would
// give every migrated note a duplicated heading.
const text = stripLeadingTitle(body, title);
return {
title,
text,
...(dayOf(note.created) ? { date: dayOf(note.created)! } : {}),
...(subjectFor(note, options.subject) ? { subject: subjectFor(note, options.subject)! } : {}),
source: 'apple-notes',
};
}
/**
* The subject a note belongs to: what the caller said, else its Notes folder.
*
* The folder is the only structure Notes has, and someone keeping lesson notes
* has almost certainly used it for exactly this. A note loose in the default
* folder gets no subject rather than a wrong one.
*/
function subjectFor(note: AppleNote, override: string | undefined): string | undefined {
if (override) return override;
const folder = note.folder?.trim();
if (!folder) return undefined;
// Notes' own default folders say nothing about a subject.
if (/^(notes|notizen|alle .*|all .*|recently deleted|zuletzt gelöscht)$/i.test(folder)) return undefined;
// A nested folder arrives as "Schule/Deutsch"; the leaf is the subject.
return folder.split('/').pop()!.trim() || undefined;
}
/**
* Apple Notes HTML as Markdown.
*
* Deliberately small. Notes emits a narrow set of tags — divs, breaks, lists,
* headings, bold/italic/underline, links and tables — and the goal is readable
* text that keeps its structure, not a faithful rendering. Anything unknown
* loses its tag and keeps its words, which is the right failure for a note.
*/
export function htmlToMarkdown(html: string | undefined | null): string {
if (!html) return '';
let value = html;
// Drop what carries no text at all before anything else looks at it.
value = value.replace(/<(script|style|head)[^>]*>[\s\S]*?<\/\1>/gi, '');
// Attachments (images, scans, drawings) come through as <object>: they have
// no text, and silently dropping them would hide that the note had one.
value = value.replace(/<object\b[^>]*>[\s\S]*?<\/object>/gi, '\n[Anhang aus Apple Notes — nicht übernommen]\n');
value = value.replace(/<img\b[^>]*>/gi, '\n[Bild aus Apple Notes — nicht übernommen]\n');
value = value.replace(/<br\s*\/?>/gi, '\n');
// `li` is deliberately absent: the next `<li>` already opens a line, and
// closing one here too would put a blank line between every bullet, which
// Markdown renders as a loose list.
value = value.replace(/<\/(p|div|tr|h[1-6]|blockquote)>/gi, '\n');
value = value.replace(/<h([1-6])[^>]*>/gi, (_all, level: string) => `\n${'#'.repeat(Number(level))} `);
// A checklist is a list in Notes and a task list in Markdown; the checked
// state lives on the li, so it has to be read before the tag is stripped.
value = value.replace(/<li\b[^>]*\bchecked\b[^>]*>/gi, '\n- [x] ');
value = value.replace(/<li[^>]*>/gi, '\n- ');
// A blank line after a list, or whatever follows is absorbed into the last
// bullet as a lazy continuation.
value = value.replace(/<\/(ul|ol|table)>/gi, '\n\n');
value = value.replace(/<(b|strong)>([\s\S]*?)<\/\1>/gi, (_all, _tag, inner: string) => emphasise(inner, '**'));
value = value.replace(/<(i|em)>([\s\S]*?)<\/\1>/gi, (_all, _tag, inner: string) => emphasise(inner, '_'));
value = value.replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_all, href: string, inner: string) => {
const label = stripTags(inner).trim();
return label ? `[${label}](${href})` : href;
});
// Table cells become separators rather than vanishing, or a row of figures
// runs into one number.
value = value.replace(/<\/(td|th)>/gi, ' | ');
value = stripTags(value);
value = decodeEntities(value);
return value
.split('\n')
.map((line) => line.replace(/[ \t ]+/g, ' ').replace(/ \| $/, '').trimEnd())
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/** Emphasis only around text that has some: `** **` renders as literal stars. */
function emphasise(inner: string, marker: string): string {
const text = inner.replace(/<br\s*\/?>/gi, '\n');
const body = stripTags(text).trim();
if (!body) return '';
return `${marker}${body}${marker}`;
}
function stripTags(value: string): string {
return value.replace(/<[^>]+>/g, '');
}
function firstLine(body: string): string | undefined {
return body
.split('\n')
.map((line) => line.replace(/^#+\s*/, '').trim())
.find((line) => line.length > 0);
}
/**
* Removes the title if the body repeats it.
*
* Notes shows a note's first line as its name, so `name` and the first line of
* `body` are usually the same string.
*/
function stripLeadingTitle(body: string, title: string): string {
const lines = body.split('\n');
const firstIndex = lines.findIndex((line) => line.trim().length > 0);
if (firstIndex === -1) return '';
const first = lines[firstIndex]!.replace(/^#+\s*/, '').replace(/^\*\*(.*)\*\*$/, '$1').trim();
if (first !== title.trim()) return body.trim();
return lines.slice(firstIndex + 1).join('\n').trim();
}
/** An ISO timestamp as a school day, or nothing when Notes gave none. */
function dayOf(value: string | undefined): string | undefined {
if (!value) return undefined;
const at = new Date(value);
if (Number.isNaN(at.getTime())) return undefined;
// The export writes local time, which is the timezone the note was taken in.
return value.slice(0, 10).match(/^\d{4}-\d{2}-\d{2}$/) ? value.slice(0, 10) : at.toISOString().slice(0, 10);
}
/** Parses the export file: one JSON object per line, blank lines ignored. */
export function parseExport(contents: string): AppleNote[] {
const notes: AppleNote[] = [];
for (const [index, line] of contents.split('\n').entries()) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
notes.push(JSON.parse(trimmed) as AppleNote);
} catch {
// One unparsable line must not cost the export; say which.
throw new Error(`Line ${index + 1} of the export is not JSON. Re-run scripts/export-apple-notes.js.`);
}
}
return notes;
}

View File

@@ -59,6 +59,38 @@ export interface FsWalk {
failures?: { path: string; reason: string }[];
}
/** One of the user's own notes, as `/api/notes` reports it. A listing omits `text`. */
export interface NoteSummary {
path: string;
title: string;
date?: string;
subject?: string;
courseId?: string;
tags: string[];
source?: string;
modifiedAt: string;
bytes: number;
text?: string;
}
export interface NoteListing {
root: string;
writable: boolean;
count: number;
notes: NoteSummary[];
}
export interface NoteInputPayload {
title: string;
text: string;
date?: string;
subject?: string;
courseId?: string;
tags?: string[];
source?: string;
append?: boolean;
}
/** The server's Schulcloud token, as `/api/token` reports it — never the token itself. */
export interface TokenInfo {
expiresAt?: string;
@@ -184,6 +216,28 @@ export class ApiClient {
return (await (await this.request(`/api/fs/find?${query}`)).json()) as FsWalk;
}
// --- the user's own notes --------------------------------------------------
async notes(filter: { subject?: string; since?: string; until?: string; limit?: number } = {}): Promise<NoteListing> {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(filter)) if (value !== undefined) query.set(key, String(value));
const suffix = query.toString() ? `?${query}` : '';
return (await (await this.request(`/api/notes${suffix}`)).json()) as NoteListing;
}
async note(path: string): Promise<NoteSummary> {
return (await (await this.request(`/api/notes?${new URLSearchParams({ path })}`)).json()) as NoteSummary;
}
async addNote(input: NoteInputPayload): Promise<NoteSummary & { appended: boolean }> {
const response = await this.request('/api/notes', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
});
return (await response.json()) as NoteSummary & { appended: boolean };
}
/** Streams one file-manager file's bytes, by path or by id. */
async fsFile(target: { path: string } | { id: string; name: string }): Promise<Response> {
const query = 'path' in target ? new URLSearchParams({ path: target.path }) : new URLSearchParams(target);
@@ -193,6 +247,8 @@ export class ApiClient {
function describe(status: number, detail: string, server: string): string {
if (status === 401) return `Unauthorized — the token is wrong or expired. Re-run: schulcloud login --server ${server} --token <token>`;
// The notes routes have their own 503, and it already says what to do.
if (status === 503 && /NOTES_DIR/.test(detail)) return detail;
if (status === 503) return 'The server is running without an index, so this command is unavailable. Set DATABASE_URL on the server.';
if (status === 409) return detail || 'The sync cursor is unknown to the server. Run a full sync with --full.';
if (status === 429) return detail || 'Refreshed too recently — wait a moment, or pass --force.';

143
src/cli/notes.ts Normal file
View File

@@ -0,0 +1,143 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { convertAppleNote, parseExport } from './apple-notes.ts';
import type { ApiClient, NoteSummary } from './client.ts';
import { writeNote } from '../core/notes.ts';
/**
* `schulcloud note` — the user's own lesson notes from the command line.
*
* The notes live on the server beside the index, so these go through /api like
* everything else here. The exception is `import --out`, which writes files
* directly: a migration of several hundred notes is worth doing offline, and
* the result can be looked at before it goes anywhere.
*/
export interface NoteWriter {
(line: string): void;
}
export async function noteList(
api: ApiClient,
filter: { subject?: string; since?: string; until?: string },
long: boolean,
out: NoteWriter,
): Promise<number> {
const listing = await api.notes(filter);
if (listing.count === 0) {
out(`No notes yet. The server keeps them in ${listing.root}.`);
return 0;
}
for (const note of listing.notes) out(formatLine(note, long));
if (listing.notes.length < listing.count) {
out(`${listing.count - listing.notes.length} more.`);
}
return 0;
}
export async function noteShow(api: ApiClient, path: string, out: NoteWriter): Promise<number> {
const note = await api.note(path);
out(`# ${note.title}`);
const facts = [note.date, note.subject, note.tags.length > 0 ? note.tags.join(', ') : undefined].filter(Boolean);
if (facts.length > 0) out(facts.join(' · '));
out('');
out(note.text ?? '');
return 0;
}
export async function noteAdd(
api: ApiClient,
input: { title: string; text: string; subject?: string; date?: string; tags?: string[]; append?: boolean },
out: NoteWriter,
): Promise<number> {
const note = await api.addNote({ ...input, source: 'cli' });
out(`${note.appended ? 'Appended to' : 'Saved'} ${note.path}`);
return 0;
}
export interface ImportOptions {
/** Write files here instead of sending them to the server. */
outDir?: string;
/** Force every note into one subject, rather than using its Notes folder. */
subject?: string;
dryRun?: boolean;
}
/**
* Migrates an Apple Notes export.
*
* Notes with no text are skipped rather than imported empty: an export always
* has some — locked notes, and notes that are one attachment — and a store
* seeded with blank entries makes every later listing worse.
*/
export async function noteImport(
api: ApiClient | undefined,
file: string,
options: ImportOptions,
out: NoteWriter,
): Promise<number> {
const contents = await readFile(resolve(file), 'utf8');
const exported = parseExport(contents);
if (exported.length === 0) {
out(`${file} holds no notes. Re-run scripts/export-apple-notes.js on the Mac.`);
return 1;
}
let imported = 0;
let empty = 0;
let failed = 0;
const unreadable: string[] = [];
for (const note of exported) {
if (note.error) {
unreadable.push(note.name || note.id);
continue;
}
const converted = convertAppleNote(note, options.subject ? { subject: options.subject } : {});
if (!converted.text.trim()) {
empty++;
continue;
}
if (options.dryRun) {
out(`would import: ${converted.date ?? '????-??-??'} · ${converted.subject ?? '—'} · ${converted.title}`);
imported++;
continue;
}
try {
if (options.outDir) {
const { note: written } = await writeNote(options.outDir, converted);
out(written.path);
} else {
if (!api) throw new Error('No server configured and no --out directory given.');
const written = await api.addNote(converted);
out(written.path);
}
imported++;
} catch (error) {
failed++;
out(`FAILED ${converted.title}: ${error instanceof Error ? error.message : String(error)}`);
}
}
out(
`${options.dryRun ? 'Would import' : 'Imported'} ${imported} of ${exported.length} note(s)` +
(empty > 0 ? `, skipped ${empty} with no text` : '') +
(unreadable.length > 0 ? `, ${unreadable.length} unreadable in Notes` : '') +
(failed > 0 ? `, FAILED ${failed}` : '') +
'.',
);
if (unreadable.length > 0) {
// Almost always locked notes: they are the ones worth naming, because the
// fix is to unlock them in Notes and export again.
out(`Unreadable (locked, or not downloaded from iCloud): ${unreadable.slice(0, 10).join('; ')}`);
}
return failed > 0 ? 1 : 0;
}
function formatLine(note: NoteSummary, long: boolean): string {
const date = note.date ?? ' ';
const subject = note.subject ? `[${note.subject}] ` : '';
if (!long) return `${date} ${subject}${note.title}`;
const tags = note.tags.length > 0 ? ` #${note.tags.join(' #')}` : '';
return `${date} ${String(note.bytes).padStart(7)} ${subject}${note.title}${tags}\n ${note.path}`;
}

View File

@@ -63,6 +63,25 @@ export interface Config {
/** How often to re-crawl on a timer. Zero = only on demand. */
crawlIntervalMs: number;
/**
* Where the user's own lesson notes live, as Markdown files. Unset = the
* note tools are not offered, the same rule the untis_* tools follow.
*/
notesDir: string | undefined;
/**
* Whether add_note may write. The notes directory is the only thing in this
* server anything can write to, so turning it off is a real setting and not
* a theoretical one — a deployment that syncs its notes in from elsewhere
* wants the files left alone.
*/
notesWritable: boolean;
/**
* How far back to read the WebUntis class register into the index. Zero =
* not at all. Costs one timetable call per 90 days plus one per lesson
* series, so a school year is a few dozen requests on a background crawl.
*/
untisHistoryDays: number;
/**
* WebUntis, where the school keeps the timetable. Unset = the untis_* tools
* are not offered at all, which is the right answer for a school that does
@@ -210,6 +229,11 @@ export function loadConfig(): Config {
// so an index without it misses whole courses. One page load per folder.
indexFileManager: bool('INDEX_FILE_MANAGER', true),
crawlIntervalMs: intAllowingZero('CRAWL_INTERVAL_MS', 6 * 60 * 60_000),
// Absolute for the same reason as the mirror: resolveWithin only returns
// an absolute path when the root it is given is one.
notesDir: process.env.NOTES_DIR?.trim() ? resolve(process.env.NOTES_DIR.trim()) : undefined,
notesWritable: !bool('NOTES_READONLY', false),
untisHistoryDays: intAllowingZero('UNTIS_HISTORY_DAYS', 180),
untis: untisConfig(),
};
}

View File

@@ -5,7 +5,9 @@ import type { SchulcloudClient } from './client.ts';
import { fetchHomeworkPage } from './homework-page.ts';
import { FileManager, type DirectoryRef, type FmFile, type WalkEntry } from './legacy-files.ts';
import { fetchLessonTaskLinks, withScrapedIds } from './lesson-page.ts';
import { readNotes, type NoteDoc } from './notes.ts';
import { htmlToText, normalizeObjectId } from './text.ts';
import type { LessonLogEntry } from './untis-history.ts';
import type { CourseMetadata, FileParentType, FileRecord, TaskContent } from './types.ts';
/**
@@ -137,6 +139,22 @@ export interface Snapshot {
files: CrawledFile[];
/** Populated only when `includePersonalFiles` is set; see that option. */
submissions: CrawledSubmission[];
/**
* The user's own lesson notes, when `notesDir` was given.
*
* Not from Schulcloud and not fetched over the network — they are read off
* disk. They travel in the snapshot because everything that consumes one
* wants them: search should find what the user wrote alongside what the
* teacher uploaded, and what_changed should notice a note appearing.
*/
notes: NoteDoc[];
/**
* The WebUntis class register for the recent past, when the caller attached
* one. The crawl never fills this itself: it is a second upstream with its
* own credential and its own request budget, so the indexer collects it and
* hangs it here rather than making every live search pay for it.
*/
lessonLog: LessonLogEntry[];
/**
* Anything that could not be read, with the reason. Boards appear here too:
* a board that fails must not vanish silently, or the index quietly loses
@@ -175,6 +193,11 @@ export interface CrawlOptions {
* second credentialled hop outside the API. Omit to leave pads unread.
*/
config?: Config;
/**
* Read the user's own notes from this directory into the snapshot. Local
* disk, so it is cheap enough for the live search path as well as the index.
*/
notesDir?: string;
courseConcurrency?: number;
boardConcurrency?: number;
onProgress?: (done: number, total: number, label: string) => void;
@@ -248,6 +271,11 @@ export async function crawl(client: SchulcloudClient, options: CrawlOptions): Pr
files.sort((a, b) => a.record.id.localeCompare(b.record.id));
submissions.sort((a, b) => a.id.localeCompare(b.id));
// Local disk, and never fatal: a notes directory that does not exist yet is
// an empty one, and a crawl must not fail over the half of the picture that
// is not Schulcloud's.
const notes = options.notesDir ? await readNotes(options.notesDir).catch(() => []) : [];
return {
crawledAt: new Date(),
schoolId: options.schoolId,
@@ -255,6 +283,8 @@ export async function crawl(client: SchulcloudClient, options: CrawlOptions): Pr
rooms,
files,
submissions,
notes,
lessonLog: [],
failures,
};
}

View File

@@ -1,5 +1,6 @@
import type { CrawledBoard, Snapshot } from './crawl.ts';
import { h5pSearchText } from './h5p.ts';
import { noteSearchText } from './notes.ts';
import { matchesAll, snippet, tokenize } from './text.ts';
/**
@@ -18,7 +19,7 @@ export interface Hit {
where: string;
/** Id to pass to a follow-up tool, with the tool that takes it. */
targetId: string;
targetKind: 'course' | 'room' | 'board' | 'lesson' | 'task' | 'file';
targetKind: 'course' | 'room' | 'board' | 'lesson' | 'task' | 'file' | 'note';
snippet: string;
}
@@ -114,6 +115,22 @@ export function searchSnapshot(snapshot: Snapshot, query: string, limit = 50): H
matchBoards(room.boards, base, terms, push);
}
// The user's own notes. `courseId` is the subject rather than an id: nothing
// follows a note back to a course, and the subject is what makes the hit
// readable — "Deutsch — my note" rather than a bare path.
for (const note of snapshot.notes) {
const haystack = noteSearchText(note);
if (!matchesAll(haystack, terms)) continue;
hits.push({
courseId: note.courseId ?? '',
courseTitle: note.subject ?? 'Notizen',
where: `my own note${note.date ? `, ${note.date}` : ''}`,
targetId: note.path,
targetKind: 'note',
snippet: snippet(haystack, terms),
});
}
for (const file of snapshot.files) {
if (matchesAll(file.record.name, terms)) {
hits.push({

466
src/core/notes.ts Normal file
View File

@@ -0,0 +1,466 @@
import { readdir, readFile, stat, mkdir, writeFile, appendFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { schoolToday } from './dates.ts';
import { resolveWithin, safeComponent } from './paths.ts';
/**
* The user's own lesson notes: a directory of Markdown files.
*
* This is the one store here that Schulcloud and WebUntis know nothing about —
* what the person in the room wrote down. It exists because the two upstreams
* between them still do not answer "what did the teacher actually say", and a
* note taken in the lesson is often the only record of it.
*
* **Plain files, not a table.** The notes have to be writable from a phone in a
* classroom and readable when Postgres is down, so the files are the truth and
* the index is only a view of them — the same split as `file_texts` and the
* mirror. It also makes migrating in a pile of exported Apple Notes a matter of
* writing files, and makes the whole store greppable, diffable and syncable by
* anything the user already runs.
*
* Frontmatter is a deliberately small YAML subset (scalars and inline lists),
* parsed here rather than by a dependency: notes are hand-written, so a strict
* parser that rejects a file is worse than a lax one that keeps the body. A
* file with no frontmatter at all is a valid note.
*/
/** Extensions treated as notes. Anything else in the directory is ignored. */
const NOTE_EXTENSIONS = ['.md', '.markdown', '.txt'];
/**
* Caps. A notes directory is user-controlled, but it may also be a synced
* folder that has just acquired somebody's 400 MB export, and a crawl must not
* turn that into an out-of-memory.
*/
const MAX_NOTE_BYTES = 512 * 1024;
const MAX_NOTES = 5_000;
const MAX_DEPTH = 8;
export interface NoteDoc {
/**
* Path relative to the notes root — `Deutsch/2026-09-15 Erörterung.md`.
* This is the note's id: there is no other, and it is what `get_note` takes.
*/
path: string;
title: string;
/** The school day the note belongs to, `YYYY-MM-DD`, when it could be determined. */
date?: string;
/** Free text as the user writes it — "Deutsch", "LF07". Not a Schulcloud id. */
subject?: string;
/** A Schulcloud course id, when the note names one, so search can group by course. */
courseId?: string;
tags: string[];
/** Where the note came from: `apple-notes`, `add_note`, or absent for a hand-written file. */
source?: string;
/** The body, without the frontmatter block. */
text: string;
/** Last write to the file, ISO. Not the lesson date — see `date` for that. */
modifiedAt: string;
bytes: number;
}
export interface NoteFrontmatter {
title?: string;
date?: string;
subject?: string;
courseId?: string;
tags?: string[];
source?: string;
/** Anything else the file carried, preserved so a round trip loses nothing. */
extra?: Record<string, string>;
}
// --- reading -------------------------------------------------------------
/**
* Every note under `root`, newest lesson first.
*
* Never throws for a missing root: a notes directory that has not been created
* yet is an empty one, and the tools say so far better than a crawl that dies.
*/
export async function readNotes(root: string): Promise<NoteDoc[]> {
const paths = await listNotePaths(root);
const notes: NoteDoc[] = [];
for (const relative of paths) {
const note = await readNoteAt(root, relative).catch(() => undefined);
if (note) notes.push(note);
}
return notes.sort(byNewest);
}
/** Relative paths of the note files under `root`, sorted for a stable order. */
export async function listNotePaths(root: string): Promise<string[]> {
const found: string[] = [];
const walk = async (relative: string, depth: number): Promise<void> => {
if (depth > MAX_DEPTH || found.length >= MAX_NOTES) return;
const absolute = relative ? resolveWithin(root, relative) : root;
let entries;
try {
entries = await readdir(absolute, { withFileTypes: true });
} catch {
// A root that does not exist yet, or a folder we may not read: an
// unreadable corner must not cost the notes that are readable.
return;
}
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
// Dotfiles are the sync tools' own business (.obsidian, .git, .stfolder)
// and never a note.
if (entry.name.startsWith('.')) continue;
const child = relative ? `${relative}/${entry.name}` : entry.name;
if (entry.isDirectory()) await walk(child, depth + 1);
else if (isNoteFile(entry.name) && found.length < MAX_NOTES) found.push(child);
}
};
await walk('', 0);
return found;
}
/** One note by its relative path. Throws `NoteNotFound` when there is none. */
export async function readNoteAt(root: string, relative: string): Promise<NoteDoc> {
const absolute = resolveWithin(root, normalizeRelative(relative));
let info;
try {
info = await stat(absolute);
} catch {
throw new NoteNotFound(relative);
}
if (!info.isFile()) throw new NoteNotFound(relative);
if (info.size > MAX_NOTE_BYTES) {
throw new Error(
`Note ${relative} is ${Math.round(info.size / 1024)} KB, past the ${MAX_NOTE_BYTES / 1024} KB limit for a note.`,
);
}
const raw = await readFile(absolute, 'utf8');
return parseNote(normalizeRelative(relative), raw, { modifiedAt: info.mtime.toISOString(), bytes: info.size });
}
export class NoteNotFound extends Error {
readonly path: string;
constructor(path: string) {
super(`No note at "${path}".`);
this.name = 'NoteNotFound';
this.path = path;
}
}
/**
* A file's text as a note.
*
* Pure, so the whole frontmatter/title/date story is testable without a disk.
*/
export function parseNote(
relative: string,
raw: string,
stamp: { modifiedAt: string; bytes: number },
): NoteDoc {
const { front, body } = splitFrontmatter(raw);
const fileName = relative.split('/').pop() ?? relative;
return {
path: relative,
title: front.title || headingTitle(body) || titleFromFileName(fileName),
// Frontmatter first, then a date the filename starts with. Never the
// file's mtime: an import writes every note today, and dating a year of
// lessons "today" would make the whole store useless for "what did we do
// before the test".
...pick('date', front.date ?? dateFromFileName(fileName)),
...pick('subject', front.subject ?? subjectFromPath(relative)),
...pick('courseId', front.courseId),
...pick('source', front.source),
tags: front.tags ?? [],
text: body.trim(),
modifiedAt: stamp.modifiedAt,
bytes: stamp.bytes,
};
}
/**
* Splits `---\nkey: value\n---\n` off the front.
*
* Only a leading block counts, and only when it closes: a note that happens to
* begin with a horizontal rule keeps its text rather than losing half of it.
*/
export function splitFrontmatter(raw: string): { front: NoteFrontmatter; body: string } {
const text = raw.replace(/^\ufeff/, '');
const open = /^---[ \t]*\r?\n/.exec(text);
if (!open) return { front: {}, body: text };
const close = /\r?\n---[ \t]*(\r?\n|$)/.exec(text.slice(open[0].length - 1));
if (!close) return { front: {}, body: text };
const end = open[0].length - 1 + close.index;
const block = text.slice(open[0].length, end);
const rest = text.slice(end + close[0].length);
const front: NoteFrontmatter = {};
const extra: Record<string, string> = {};
for (const line of block.split(/\r?\n/)) {
const match = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line.trim());
if (!match) continue;
const key = match[1]!.toLowerCase();
const value = unquote(match[2]!.trim());
if (!value) continue;
switch (key) {
case 'title':
front.title = value;
break;
case 'date':
front.date = normalizeDate(value);
break;
case 'subject':
case 'fach':
front.subject = value;
break;
case 'courseid':
case 'course':
front.courseId = value;
break;
case 'source':
front.source = value;
break;
case 'tags':
front.tags = parseList(value);
break;
default:
extra[key] = value;
}
}
if (Object.keys(extra).length > 0) front.extra = extra;
return { front, body: rest };
}
// --- writing -------------------------------------------------------------
export interface NoteInput {
title: string;
text: string;
/** The lesson's day. Defaults to today in the school's timezone. */
date?: string;
subject?: string;
courseId?: string;
tags?: string[];
source?: string;
/** Write here instead of deriving a path from subject, date and title. */
path?: string;
/**
* Add to the note at that path if it already exists, rather than creating a
* second one. This is what makes a lesson's notes accumulate in one file as
* they are taken, which is how anyone actually takes them.
*/
append?: boolean;
}
/**
* Creates a note, or appends to one.
*
* Every component of the path goes through `safeComponent`: the title and
* subject arrive from a tool call, so they are untrusted input that becomes a
* filename, exactly as course titles do in the mirror.
*/
export async function writeNote(root: string, input: NoteInput): Promise<{ note: NoteDoc; appended: boolean }> {
const date = input.date ?? schoolToday();
// Appending is about a *lesson*, not about a title: "note this down too" in
// the middle of Tuesday's German lesson means the note already open for
// Tuesday and German, whatever it happens to be called. Deriving the path
// from the new title instead would start a second note every time, which is
// the one thing append exists to prevent.
const relative = input.path
? normalizeRelative(input.path)
: ((input.append ? await noteForLesson(root, date, input.subject) : undefined) ??
notePathFor({ date, subject: input.subject, title: input.title }));
const absolute = resolveWithin(root, relative);
const existing = await stat(absolute).then(
() => true,
() => false,
);
if (existing && input.append) {
// A heading rather than a bare paragraph, so a note built from four
// appends still reads as four things and not as one run-on.
await appendFile(absolute, `\n\n## ${input.title}\n\n${input.text.trim()}\n`, 'utf8');
return { note: await readNoteAt(root, relative), appended: true };
}
// Never overwrite: a note is the only copy of what someone wrote down, and a
// second note with the same title on the same day is a normal thing to have.
const target = existing ? await freePath(root, relative) : relative;
await mkdir(dirname(resolveWithin(root, target)), { recursive: true });
await writeFile(
resolveWithin(root, target),
renderNote(
{
title: input.title,
date,
...pick('subject', input.subject),
...pick('courseId', input.courseId),
...pick('source', input.source),
...(input.tags && input.tags.length > 0 ? { tags: input.tags } : {}),
},
input.text,
),
'utf8',
);
return { note: await readNoteAt(root, target), appended: false };
}
/** A note as it is stored: frontmatter, then the body. */
export function renderNote(front: NoteFrontmatter, body: string): string {
const lines = [
front.title !== undefined && `title: ${quote(front.title)}`,
front.date !== undefined && `date: ${front.date}`,
front.subject !== undefined && `subject: ${quote(front.subject)}`,
front.courseId !== undefined && `courseId: ${front.courseId}`,
front.tags && front.tags.length > 0 && `tags: [${front.tags.map((tag) => quote(tag)).join(', ')}]`,
front.source !== undefined && `source: ${quote(front.source)}`,
...Object.entries(front.extra ?? {}).map(([key, value]) => `${key}: ${quote(value)}`),
].filter((line): line is string => typeof line === 'string');
return `---\n${lines.join('\n')}\n---\n\n${body.trim()}\n`;
}
/**
* Where a new note goes: `Deutsch/2026-09-15 Erörterung.md`.
*
* Subject-first because that is how anyone looks for a note by hand, and the
* date leads the filename so a folder sorts chronologically in every file
* browser there is.
*/
export function notePathFor(input: { date: string; subject?: string; title: string }): string {
const folder = safeComponent(input.subject ?? 'Allgemein', 'Allgemein');
const name = safeComponent(`${input.date} ${input.title}`, input.date);
return `${folder}/${name}.md`;
}
/**
* The note already written for this day and subject, if there is one.
*
* The newest by path, so a day that somehow grew two notes still gets the one
* a person would reach for.
*/
async function noteForLesson(root: string, date: string, subject: string | undefined): Promise<string | undefined> {
const wanted = subject?.trim().toLowerCase();
const candidates = (await readNotes(root)).filter(
(note) => note.date === date && (note.subject ?? '').toLowerCase() === (wanted ?? ''),
);
return candidates[0]?.path;
}
/** `note.md` → `note 2.md`, for the day someone titles two notes the same. */
async function freePath(root: string, relative: string): Promise<string> {
const dot = relative.lastIndexOf('.');
const stem = dot > 0 ? relative.slice(0, dot) : relative;
const ext = dot > 0 ? relative.slice(dot) : '';
for (let n = 2; n < 100; n++) {
const candidate = `${stem} ${n}${ext}`;
const taken = await stat(resolveWithin(root, candidate)).then(
() => true,
() => false,
);
if (!taken) return candidate;
}
throw new Error(`Too many notes named like ${relative}.`);
}
// --- matching ------------------------------------------------------------
/** Everything about a note that search should look at, as one string. */
export function noteSearchText(note: NoteDoc): string {
return [note.title, note.subject, note.tags.join(' '), note.text].filter(Boolean).join('\n');
}
/** Filters a list the way `list_notes` does. Pure, and shared with the CLI. */
export function filterNotes(
notes: NoteDoc[],
filter: { subject?: string; since?: string; until?: string; courseId?: string },
): NoteDoc[] {
const subject = filter.subject?.trim().toLowerCase();
return notes.filter((note) => {
if (subject && !(note.subject ?? '').toLowerCase().includes(subject)) return false;
if (filter.courseId && note.courseId !== filter.courseId) return false;
// A note with no date cannot be excluded by a date window without
// silently hiding it; undated notes always pass.
if (filter.since && note.date && note.date < filter.since) return false;
if (filter.until && note.date && note.date > filter.until) return false;
return true;
});
}
// --- helpers -------------------------------------------------------------
function isNoteFile(name: string): boolean {
const lower = name.toLowerCase();
return NOTE_EXTENSIONS.some((extension) => lower.endsWith(extension));
}
/**
* A caller's path in the one form the rest of this module uses.
*
* Leading slashes and backslashes are accepted and normalised because people
* paste `/Deutsch/…` from a listing; traversal is not — `resolveWithin` refuses
* it, and this must not quietly make it look legal first.
*/
export function normalizeRelative(path: string): string {
return path.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/').trim();
}
function byNewest(a: NoteDoc, b: NoteDoc): number {
// Undated notes sort last: they are usually imports that never carried a
// date, and they should not head a list of "the last few lessons".
if (a.date && b.date && a.date !== b.date) return b.date.localeCompare(a.date);
if (a.date && !b.date) return -1;
if (!a.date && b.date) return 1;
return b.modifiedAt.localeCompare(a.modifiedAt) || a.path.localeCompare(b.path);
}
function pick<K extends string>(key: K, value: string | undefined): Partial<Record<K, string>> {
return value ? ({ [key]: value } as Record<K, string>) : {};
}
function headingTitle(body: string): string | undefined {
const match = /^\s*#\s+(.+)$/m.exec(body);
return match?.[1]?.trim();
}
function titleFromFileName(fileName: string): string {
const withoutExtension = fileName.replace(/\.(md|markdown|txt)$/i, '');
return withoutExtension.replace(/^\d{4}-\d{2}-\d{2}[ _-]*/, '').trim() || withoutExtension;
}
function dateFromFileName(fileName: string): string | undefined {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(fileName);
return match ? `${match[1]}-${match[2]}-${match[3]}` : undefined;
}
/** The first folder is the subject, by the layout `notePathFor` writes. */
function subjectFromPath(relative: string): string | undefined {
const parts = relative.split('/');
return parts.length > 1 ? parts[0] : undefined;
}
/** `15.09.2026` and `2026-09-15T08:00:00Z` both mean the same school day. */
function normalizeDate(value: string): string | undefined {
const german = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(value);
if (german) return `${german[3]}-${german[2]!.padStart(2, '0')}-${german[1]!.padStart(2, '0')}`;
const iso = /^(\d{4}-\d{2}-\d{2})/.exec(value);
return iso ? iso[1] : undefined;
}
function parseList(value: string): string[] {
const inner = /^\[(.*)\]$/.exec(value)?.[1] ?? value;
return inner
.split(',')
.map((entry) => unquote(entry.trim()))
.filter(Boolean);
}
function unquote(value: string): string {
const match = /^(['"])(.*)\1$/.exec(value);
return match ? match[2]! : value;
}
/** Quotes only when the value would otherwise change meaning on the way back in. */
function quote(value: string): string {
const clean = value.replace(/[\r\n]+/g, ' ').trim();
return /^[\w äöüÄÖÜß.,/()+-]+$/.test(clean) && !/^\[/.test(clean) ? clean : JSON.stringify(clean);
}

178
src/core/untis-history.ts Normal file
View File

@@ -0,0 +1,178 @@
import { addDays, daysBetween } from './dates.ts';
import type { UntisClient, UntisLesson } from './untis.ts';
/**
* The class register, read backwards: what every past lesson actually covered.
*
* `untis_lesson_topics` answers this one series at a time, from a period id the
* caller already has. That is the wrong shape for two of the questions this
* exists for — "what have we done in Deutsch this term" and "where does the
* material about X come from" — because neither starts from a period id, and
* the second needs the text in the search index rather than in a tool call.
*
* So this walks a date range and merges the two halves the API keeps apart:
*
* - `getTimetable2017` gives the periods, and with them what a teacher wrote on
* one (`text.info` is where this school announces its tests), the homework and
* any exam.
* - `getLessonTopic2017` gives the `Unterrichtsinhalt` — but only per *series*,
* as "the previous topics of this lesson". One call per series therefore
* covers all of its past lessons at once, which is why this asks per series
* and not per period: a term is a few dozen calls, not a few hundred.
*
* Requests are sequential on purpose. Every WebUntis call carries its own
* one-time code, and the index is built by a background crawl that nobody is
* waiting on, so there is nothing to buy by running them in parallel.
*/
/** The longest range one `getTimetable2017` call is asked for. */
const CHUNK_DAYS = 90;
export interface LessonLogEntry {
periodId: number;
/** The series id: every Tuesday-second-period German lesson shares it. */
lessonId: number;
date: string;
start: string;
end: string;
subject?: string;
subjectLong?: string;
teachers: string[];
/** The class register's "Unterrichtsinhalt" for this period, when the teacher filled it in. */
topic?: string;
/** The free-text fields on the period. `info` is where announced tests live. */
notes: { lesson?: string; substitution?: string; info?: string };
homework: { text: string; due: string }[];
exam?: string;
}
export interface LessonLog {
from: string;
to: string;
entries: LessonLogEntry[];
/** Series whose topics could not be read, so a gap is visible rather than silent. */
failures: { lessonId: number; reason: string }[];
/** Periods seen in the range, including the ones that carried nothing. */
periodsSeen: number;
}
/**
* Collects the log for `[from, to]`.
*
* Only periods that carry something are returned: a lesson with neither a
* topic, nor a note, nor homework, nor an exam has nothing to say, and
* indexing it would bury the ones that do under a term of empty rows.
*/
export async function collectLessonLog(
untis: UntisClient,
options: { from: string; to: string; subject?: string },
): Promise<LessonLog> {
const lessons: UntisLesson[] = [];
for (const [chunkFrom, chunkTo] of chunkRange(options.from, options.to)) {
const table = await untis.timetable(chunkFrom, chunkTo);
for (const day of table.days) lessons.push(...day.lessons);
}
// A cancelled period taught nothing, and its replacement beside it carries
// whatever actually happened.
const held = lessons.filter((lesson) => !lesson.cancelled).filter((lesson) => matchesSubject(lesson, options.subject));
const topics = new Map<number, string>();
const failures: { lessonId: number; reason: string }[] = [];
for (const [lessonId, periodId] of latestPeriodPerSeries(held)) {
try {
for (const topic of await untis.lessonTopics(periodId)) topics.set(topic.periodId, topic.text);
} catch (error) {
// One series the register refuses must not cost the rest of the term.
failures.push({ lessonId, reason: error instanceof Error ? error.message : String(error) });
}
}
const entries = held
.map((lesson): LessonLogEntry => {
const subject = lesson.subjects[0];
return {
periodId: lesson.periodId,
lessonId: lesson.lessonId,
date: lesson.date,
start: lesson.start,
end: lesson.end,
...(subject?.name ? { subject: subject.name } : {}),
...(subject?.longName ? { subjectLong: subject.longName } : {}),
teachers: lesson.teachers.map((teacher) => teacher.longName || teacher.name),
...(topics.get(lesson.periodId) ? { topic: topics.get(lesson.periodId) } : {}),
notes: lesson.notes,
homework: lesson.homework.map((item) => ({ text: item.text, due: item.due })),
...(lesson.exam ? { exam: lesson.exam } : {}),
};
})
.filter(hasContent)
.sort((a, b) => b.date.localeCompare(a.date) || b.start.localeCompare(a.start));
return { from: options.from, to: options.to, entries, failures, periodsSeen: held.length };
}
/** True when the entry records anything worth keeping. */
export function hasContent(entry: LessonLogEntry): boolean {
return Boolean(
entry.topic ||
entry.notes.lesson ||
entry.notes.info ||
entry.notes.substitution ||
entry.exam ||
entry.homework.length > 0,
);
}
/** Everything the entry says, as one string — the body the index gets. */
export function lessonLogText(entry: LessonLogEntry): string {
return [
entry.topic,
entry.notes.info,
entry.notes.lesson,
entry.notes.substitution,
entry.exam ? `Prüfung: ${entry.exam}` : undefined,
...entry.homework.map((item) => `Hausaufgabe bis ${item.due}: ${item.text}`),
]
.filter(Boolean)
.join('\n');
}
/** `LF07` matches the subject `LF07` and the long name `Lernfeld 7`. */
function matchesSubject(lesson: UntisLesson, subject: string | undefined): boolean {
if (!subject) return true;
const wanted = subject.trim().toLowerCase();
return lesson.subjects.some(
(entry) =>
entry.name.toLowerCase().includes(wanted) || (entry.longName ?? '').toLowerCase().includes(wanted),
);
}
/**
* One period id per series — the latest one.
*
* `getLessonTopic2017` answers with the topics of the lessons *before* the
* period it is given, so asking about the last lesson of a series reaches the
* whole of its history and asking about the first reaches none of it.
*/
function latestPeriodPerSeries(lessons: UntisLesson[]): Map<number, number> {
const latest = new Map<number, { periodId: number; at: string }>();
for (const lesson of lessons) {
const at = `${lesson.date} ${lesson.start}`;
const current = latest.get(lesson.lessonId);
if (!current || at > current.at) latest.set(lesson.lessonId, { periodId: lesson.periodId, at });
}
return new Map([...latest].map(([lessonId, entry]) => [lessonId, entry.periodId]));
}
/** Splits a range into windows the timetable call will accept. */
export function chunkRange(from: string, to: string): [string, string][] {
const chunks: [string, string][] = [];
let start = from;
while (start <= to) {
const end = daysBetween(start, to) > CHUNK_DAYS ? addDays(start, CHUNK_DAYS) : to;
chunks.push([start, end]);
start = addDays(end, 1);
}
return chunks;
}

View File

@@ -12,6 +12,7 @@ import {
type FsErrorCode,
type WalkEntry,
} from '../core/legacy-files.ts';
import { filterNotes, NoteNotFound, readNoteAt, readNotes, writeNote } from '../core/notes.ts';
import { resolveWithin } from '../core/paths.ts';
import { TokenRejected } from '../core/session-token.ts';
import type { Services } from '../services.ts';
@@ -28,6 +29,9 @@ import type { Services } from '../services.ts';
* index and mirror, `/token` only to the server's own token, and every upstream
* call either triggers is a GET.
*/
const NO_NOTES_DIR =
'This server keeps no notes: NOTES_DIR is not set on it. See docs/NOTES.md.';
export function createApiRouter(services: Services): Router {
const router = express.Router();
@@ -237,6 +241,72 @@ export function createApiRouter(services: Services): Router {
// The only upstream call is the GET /me a replacement must pass first. Works
// without an index, since a server without one still needs a token.
// --- the user's own lesson notes ----------------------------------------
//
// Read off disk, like the fs_* routes read Schulcloud: no index involved, so
// these answer before the first crawl and while Postgres is down. The POST is
// the only write in this server that is not the index or its own token, and
// it can reach nothing but the notes directory — `writeNote` builds every
// path component with `safeComponent` and checks the result with
// `resolveWithin`.
router.get('/notes', async (req: Request, res: Response) => {
const root = services.config.notesDir;
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });
try {
const path = stringParam(req.query.path);
if (path) return res.json(await readNoteAt(root, path));
const notes = filterNotes(await readNotes(root), {
...pickParam('subject', req.query.subject),
...pickParam('since', req.query.since),
...pickParam('until', req.query.until),
...pickParam('courseId', req.query.courseId),
});
const limit = Math.min(Number.parseInt(stringParam(req.query.limit) ?? '', 10) || 500, 2000);
return res.json({
root,
writable: services.config.notesWritable,
count: notes.length,
// The body is dropped from a listing: a term of notes is megabytes,
// and the CLI asks for the ones it wants by path.
notes: notes.slice(0, limit).map(({ text, ...rest }) => rest),
});
} catch (error) {
if (error instanceof NoteNotFound) return res.status(404).json({ error: 'not_found', message: error.message });
return fail(res, error, 'notes');
}
});
router.post('/notes', express.json({ limit: '1mb' }), async (req: Request, res: Response) => {
const root = services.config.notesDir;
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });
if (!services.config.notesWritable) {
return res.status(403).json({ error: 'notes_readonly', message: 'This server was started with NOTES_READONLY.' });
}
const body = (req.body ?? {}) as Record<string, unknown>;
const title = typeof body.title === 'string' ? body.title.trim() : '';
const noteText = typeof body.text === 'string' ? body.text : '';
if (!title || !noteText.trim()) {
return res.status(400).json({ error: 'invalid', message: 'A note needs a title and some text.' });
}
try {
const { note, appended } = await writeNote(root, {
title,
text: noteText,
...pickParam('date', body.date),
...pickParam('subject', body.subject),
...pickParam('courseId', body.courseId),
...pickParam('path', body.path),
...pickParam('source', body.source),
...(Array.isArray(body.tags) ? { tags: body.tags.filter((tag): tag is string => typeof tag === 'string') } : {}),
append: body.append === true,
});
return res.status(appended ? 200 : 201).json({ ...note, appended });
} catch (error) {
return fail(res, error, 'save a note');
}
});
router.get('/token', (_req: Request, res: Response) => {
res.json(tokenStatus(services));
});
@@ -352,6 +422,16 @@ function stringParam(value: unknown): string | undefined {
return typeof first === 'string' && first.length > 0 ? first : undefined;
}
/**
* A query or body value as an optional field, so callers can spread it into an
* options object without turning "not given" into `undefined` the way an
* exactOptionalPropertyTypes build rejects.
*/
function pickParam<K extends string>(key: K, value: unknown): Partial<Record<K, string>> {
const text = stringParam(value);
return text ? ({ [key]: text } as Record<K, string>) : {};
}
function boundedInt(value: unknown, fallback: number, min: number, max: number): number {
const parsed = Number.parseInt(stringParam(value) ?? '', 10);
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;

View File

@@ -5,6 +5,9 @@ import type { DownloadedFile, SchulcloudClient } from '../core/client.ts';
import { crawl, forEachLimited, type Snapshot } from '../core/crawl.ts';
import { extractContent, formatBytes } from '../core/extract.ts';
import { mirrorPath, resolveWithin } from '../core/paths.ts';
import { addDays, schoolToday } from '../core/dates.ts';
import { collectLessonLog, type LessonLogEntry } from '../core/untis-history.ts';
import type { UntisClient } from '../core/untis.ts';
import type { Store } from '../store/store.ts';
/**
@@ -25,6 +28,10 @@ export interface IndexResult {
courses: number;
/** Rooms walked. Zero is normal — many accounts are in none. */
rooms: number;
/** The user's own notes picked up from NOTES_DIR. */
notes: number;
/** Class-register entries read from WebUntis. Zero without a key, or without a register. */
lessons: number;
files: number;
mirrored: number;
extracted: number;
@@ -48,6 +55,8 @@ export class Indexer {
private readonly store: Store;
private readonly config: Config;
private readonly minIntervalMs: number;
/** WebUntis, when configured: the class register is indexed alongside Schulcloud. */
private readonly untis: UntisClient | undefined;
private inFlight = new Map<string, Promise<IndexResult>>();
private startedAt: Date | undefined;
@@ -56,11 +65,17 @@ export class Indexer {
private lastResult: IndexResult | undefined;
private lastError: string | undefined;
constructor(client: SchulcloudClient, store: Store, config: Config, minIntervalMs = 60_000) {
constructor(
client: SchulcloudClient,
store: Store,
config: Config,
options: { untis?: UntisClient; minIntervalMs?: number } = {},
) {
this.client = client;
this.store = store;
this.config = config;
this.minIntervalMs = minIntervalMs;
this.untis = options.untis;
this.minIntervalMs = options.minIntervalMs ?? 60_000;
}
status(): IndexerStatus {
@@ -133,8 +148,14 @@ export class Indexer {
includePersonalFiles: this.config.indexPersonalFiles,
includeFileManager: this.config.indexFileManager,
config: this.config,
// Notes and the class register belong to the whole account, not to
// one course, so a per-course refresh leaves them alone and the
// store's carry-forward keeps the previous generation's rows.
...(scope === 'full' && this.config.notesDir ? { notesDir: this.config.notesDir } : {}),
});
if (scope === 'full') snapshot.lessonLog = await this.readLessonLog();
const crawlId = await this.store.saveSnapshot(snapshot, scope);
const { mirrored, extracted, skipped } = await this.ingestFiles(snapshot);
@@ -143,6 +164,8 @@ export class Indexer {
scope,
courses: snapshot.courses.length,
rooms: snapshot.rooms.length,
notes: snapshot.notes.length,
lessons: snapshot.lessonLog.length,
files: snapshot.files.length,
mirrored,
extracted,
@@ -159,6 +182,34 @@ export class Indexer {
}
}
/**
* The WebUntis class register for the configured window.
*
* Never fatal. WebUntis is a second upstream with its own key, its own
* clock requirement and its own outages, and a Schulcloud crawl that failed
* because the timetable server was down would be the wrong trade entirely.
*/
private async readLessonLog(): Promise<LessonLogEntry[]> {
const days = this.config.untisHistoryDays;
if (!this.untis || days <= 0) return [];
const today = schoolToday();
try {
const log = await collectLessonLog(this.untis, { from: addDays(today, -days), to: today });
if (log.failures.length > 0) {
console.warn(
`[schulcloud-mcp] class register: ${log.failures.length} lesson series could not be read; ` +
'their topics are missing from the index.',
);
}
return log.entries;
} catch (error) {
console.warn(
`[schulcloud-mcp] class register not indexed: ${error instanceof Error ? error.message : String(error)}`,
);
return [];
}
}
/**
* Downloads, mirrors and extracts every file the index has no text for.
*

View File

@@ -26,6 +26,23 @@ export interface Target {
name: string;
}
/**
* Which of the three sources this server actually has.
*
* A prompt that tells Claude to read the class register on a deployment with no
* WebUntis key, or the user's notes where there are none, spends a turn on a
* tool that is not there and then explains itself — so the instructions name
* only what exists.
*/
export interface Sources {
notes: boolean;
untis: boolean;
}
function sourcesOf(context: ServerContext): Sources {
return { notes: Boolean(context.config.notesDir), untis: Boolean(context.untis) };
}
const COURSE_ARGUMENT = z
.string()
.describe('Kurs oder Raum: ein eindeutiger Teil des Namens oder die ID. Mehrere Wörter mit _ verbinden, z. B. Mathe_10b.');
@@ -48,7 +65,12 @@ export function registerPrompts(server: McpServer, context: ServerContext): void
},
async ({ kurs, fokus }) => {
const target = await findTarget(context, kurs);
return withOverview(context, target, 'Zusammenfassung', summaryPrompt(target, argumentText(fokus)));
return withOverview(
context,
target,
'Zusammenfassung',
summaryPrompt(target, argumentText(fokus), sourcesOf(context)),
);
},
);
@@ -77,6 +99,7 @@ export function registerPrompts(server: McpServer, context: ServerContext): void
topic: argumentText(thema),
date: argumentText(datum),
today: germanDate(new Date()),
sources: sourcesOf(context),
});
return withOverview(context, target, 'Prüfungsvorbereitung', text);
},
@@ -112,7 +135,7 @@ export function registerPrompts(server: McpServer, context: ServerContext): void
description: `Tagesvorbereitung: ${germanWeekday(date)}, ${germanDay(date)}`,
messages: [
{ role: 'user', content: { type: 'text', text: timetable } },
{ role: 'user', content: { type: 'text', text: dayPrompt(date) } },
{ role: 'user', content: { type: 'text', text: dayPrompt(date, sourcesOf(context)) } },
],
};
},
@@ -266,11 +289,17 @@ async function withOverview(
// --- prompt texts ----------------------------------------------------------
/**
* The default for a call that names no sources: the tests and any older caller.
* Naming nothing is safe; naming a tool that is not registered is not.
*/
const NO_SOURCES: Sources = { notes: false, untis: false };
const UNREADABLE =
'Eingescannte PDFs ohne Textebene und noch nicht veröffentlichte Boards kannst du nicht lesen. ' +
'Sag, was dir dadurch fehlt, statt es stillschweigend zu übergehen.';
export function summaryPrompt(target: Target, focus?: string): string {
export function summaryPrompt(target: Target, focus?: string, sources: Sources = NO_SOURCES): string {
const course = target.kind === 'course';
return joinSections([
`Fasse ${course ? 'den Kurs' : 'den Raum'}${target.name}“ für mich zusammen. Die Übersicht aus der Schulcloud ist angehängt.`,
@@ -281,6 +310,12 @@ export function summaryPrompt(target: Target, focus?: string): string {
course &&
`Sieh dir auch die Kurs-Dateien an (fs_tree mit dem Pfad "/courses/${target.id}") und lies die aussagekräftigsten ` +
'Dateien mit fs_read. Viele Lehrkräfte legen ihr Material nur dort ab, dann wirkt die Kursseite fast leer.',
sources.notes &&
'Sieh dir meine eigenen Mitschriften an (list_notes, bei Bedarf mit dem Fach). Sie sagen, was im ' +
'Unterricht wirklich betont wurde — das steht in keinem hochgeladenen Material.',
sources.untis &&
'Frag das Klassenbuch (untis_lesson_topics mit dem Fach). Dort steht, was in welcher Stunde ' +
'behandelt wurde, also die Reihenfolge des Unterrichts, die die Kursseite nicht verrät.',
'Wenn es sehr viel Material gibt, lies zuerst das Neueste und das, was einen Überblick gibt (Arbeitsblätter, ' +
'Präsentationen, Zusammenfassungen), und sag mir, was du ausgelassen hast.',
focus && `Konzentriere dich auf: ${focus}.`,
@@ -289,6 +324,9 @@ export function summaryPrompt(target: Target, focus?: string): string {
`**Worum es geht:** Ziel und Inhalt ${course ? 'des Kurses' : 'des Raums'} in zwei, drei Sätzen.`,
'**Themen:** die behandelten Themen, möglichst in der Reihenfolge des Unterrichts, jeweils mit den wichtigsten ' +
'Inhalten und Fachbegriffen.',
sources.notes &&
'**Aus meinen Mitschriften:** was ich mir notiert habe und im Material nicht steht, als eigener Punkt ' +
'und als Zitat kenntlich.',
course && '**Aufgaben:** was zu erledigen war oder ist, mit Fälligkeit, ob ich abgegeben habe und wie es bewertet wurde.',
'**Wichtige Materialien:** die Boards und Dateien, die man kennen sollte, mit Namen, damit ich sie wiederfinde.',
'**Lücken:** was fehlt, unklar ist oder nicht gelesen werden konnte.',
@@ -302,8 +340,12 @@ export function summaryPrompt(target: Target, focus?: string): string {
]);
}
export function examPrompt(target: Target, options: { topic?: string; date?: string; today: string }): string {
export function examPrompt(
target: Target,
options: { topic?: string; date?: string; today: string; sources?: Sources },
): string {
const course = target.kind === 'course';
const sources = options.sources ?? NO_SOURCES;
return joinSections([
`Hilf mir, mich auf eine Prüfung ${course ? 'im Kurs' : 'im Raum'}${target.name}“ vorzubereiten. ` +
'Die Übersicht aus der Schulcloud ist angehängt.',
@@ -320,18 +362,30 @@ export function examPrompt(target: Target, options: { topic?: string; date?: str
`Durchsuche auch die Kurs-Dateien (fs_tree oder fs_find mit dem Pfad "/courses/${target.id}") und lies die ` +
'passenden Dateien mit fs_read. Viele Lehrkräfte legen ihr Material nur dort ab.',
options.topic && 'Mit search findest du das Thema auch im Text von Dateien.',
sources.untis &&
'Sieh im Klassenbuch nach, was tatsächlich unterrichtet wurde (untis_lesson_topics mit dem Fach, ' +
'sonst mit einer periodId aus untis_timetable). Geprüft wird, was drankam — nicht, was hochgeladen ' +
'wurde. Dort stehen oft auch die Ankündigung der Arbeit und ihr Stoff.',
sources.notes &&
'Lies meine eigenen Mitschriften zum Fach (list_notes, dann get_note; search findet sie auch im Text). ' +
'Was ich mir aufgeschrieben habe, ist meist genau das, was die Lehrkraft betont hat — und damit der ' +
'beste Hinweis auf den Prüfungsstoff. Wenn eine Mitschrift dem Material widerspricht, sag es.',
course &&
'Sieh dir meine Abgaben und das Feedback dazu an (get_task, list_submissions für diesen Kurs). Daran erkennst ' +
'du, was ich schon kann und wo ich nacharbeiten sollte.',
])}`,
`Erstelle daraus:\n${bulleted([
'**Prüfungsstoff:** die Themen, die drankommen können, jeweils mit Quelle.',
'**Prüfungsstoff:** die Themen, die drankommen können, jeweils mit Quelle. Was im Unterricht behandelt ' +
'wurde, wiegt schwerer als Material, das nur bereitliegt.',
'**Das Wichtigste:** Kernbegriffe, Definitionen, Zusammenhänge und Verfahren, knapp und verständlich erklärt.',
'**Typische Aufgaben:** welche Arten von Aufgaben im Unterricht vorkamen, jeweils mit einem Beispiel.',
'**Übungsfragen:** 8 bis 12 Fragen mit steigender Schwierigkeit. Die Lösungen stehen gesammelt am Ende, damit ' +
'ich erst selbst nachdenken kann.',
`**Lernplan:** ${options.date ? 'Tag für Tag bis zur Prüfung' : 'eine sinnvolle Reihenfolge der Themen'}, mit Zeit zum Wiederholen.`,
course && '**Nacharbeiten:** Stellen, an denen Feedback oder Bewertungen Lücken zeigen, falls es welche gibt.',
sources.notes &&
'**Lücken in meinen Mitschriften:** Stunden zum Prüfungsstoff, zu denen ich nichts notiert habe — ' +
'dort muss ich mich auf das Material verlassen.',
])}`,
`Wichtig:\n${bulleted([
'Stütze dich auf das Material aus der Schulcloud und nenne die Quellen. Was du aus eigenem Wissen ergänzt, kennzeichnest du.',
@@ -351,7 +405,7 @@ export function examPrompt(target: Target, options: { topic?: string; date?: str
* separate on purpose, because a teacher uses one or the other and a merged
* list quietly drops half.
*/
export function dayPrompt(date: string): string {
export function dayPrompt(date: string, sources: Sources = NO_SOURCES): string {
return joinSections([
`Bereite mich auf den Schultag am ${germanWeekday(date)}, ${germanDay(date)} vor. Der Stundenplan aus ` +
'WebUntis steht oben.',
@@ -363,6 +417,9 @@ export function dayPrompt(date: string): string {
'und das, was daran hängt (get_board, get_lesson), sowie die Kurs-Dateien (fs_tree, fs_read).',
'Mit untis_lesson_topics und der periodId einer Stunde siehst du, was im Unterricht zuletzt behandelt ' +
'wurde. Daran erkennst du, was als Nächstes dran ist.',
sources.notes &&
'Sieh dir zu den Fächern des Tages meine eigenen Mitschriften der letzten Stunden an (list_notes mit ' +
'dem Fach und since). Offene Fragen und Angekündigtes stehen oft nur dort.',
'Prüfe, was fällig ist: list_tasks für die Schulcloud-Aufgaben und untis_homework für die Hausaufgaben ' +
'aus dem Klassenbuch. Das sind zwei getrennte Listen.',
'Lies die Notizen an den Stunden im Stundenplan. Angekündigte Tests und Leistungskontrollen stehen ' +
@@ -372,6 +429,7 @@ export function dayPrompt(date: string): string {
'**Der Tag:** je Stunde Zeit, Fach, Raum und Lehrkraft, bei Änderungen mit einem Wort dazu.',
'**Je Fach:** worum es zuletzt ging, was voraussichtlich dran ist, und was ich mir dafür ansehen sollte — ' +
'jeweils mit Quelle, damit ich es wiederfinde.',
sources.notes && '**Aus meinen Mitschriften:** offene Fragen und Merkposten aus den letzten Stunden.',
'**Vorbereiten und mitbringen:** konkrete Punkte aus den Notizen, Hausaufgaben und Aufgaben.',
'**Fällig:** Aufgaben und Hausaufgaben mit Datum, das von heute und morgen zuerst.',
'**Angekündigt:** Tests, Leistungskontrollen und Prüfungen, mit Datum und Fach.',

View File

@@ -11,6 +11,7 @@ import { registerH5pTools } from './tools/h5p.ts';
import { registerOverviewTools } from './tools/overview.ts';
import { registerRawTool } from './tools/raw.ts';
import { registerIndexTools } from './tools/index-tools.ts';
import { registerNoteTools } from './tools/notes.ts';
import { registerSearchTool } from './tools/search.ts';
import { registerRoomTools } from './tools/rooms.ts';
import { registerSubmissionTools } from './tools/submissions.ts';
@@ -48,13 +49,22 @@ How the content is organised, and the usual path through it:
graded submission, say it was not found rather than that none was given. On a teacher account these
tools report other people's submissions too.
**The user's own notes are a third source, and often the best one.** When the note tools are listed, the user
keeps notes from their lessons as Markdown files: list_notes and get_note read them, search finds them by
content, and add_note writes one. They record what a teacher said and stressed, which no upload does — so
consult them whenever the question is what was covered in class, what a topic means "the way we did it", or
what to revise for a test, and say when a note disagrees with the material. Notes are the user's own words:
quote them, do not silently correct them.
**The timetable is not in Schulcloud.** When the untis_* tools are listed, the school's schedule lives in
WebUntis and they are the only way to it: untis_timetable says which lessons a day actually holds, what was
cancelled ("Entfall"), what is a substitution ("Vertretung") and what a teacher noted on a period — announced
tests are usually in those notes. Schulcloud holds the material for those lessons, so the two go together:
take the subject from untis_timetable, then find its course with list_courses. untis_homework is the class
register's homework, which is a different list from Schulcloud's tasks; check both. untis_lesson_topics says
what previous lessons of a subject actually covered.
what previous lessons of a subject actually covered — pass it a subject to read back over a whole term, which
is the fastest way to reconstruct what a course has done. Those class-register entries are in the index too,
so search finds them beside the Schulcloud material.
When the user names a topic rather than a course, use search — the API has no search endpoint, so it walks the
courses and matches client-side, which takes a few seconds but covers board text and file names.
@@ -62,7 +72,9 @@ courses and matches client-side, which takes a few seconds but covers board text
The user can also attach a course or room directly (resources schulcloud://courses/<id> and schulcloud://rooms/<id>).
An attached one is exactly what get_course or get_room returns, so do not fetch it again — continue from its ids.
Everything here is read-only; nothing in this server can modify the account.`;
Everything that touches Schulcloud and WebUntis is read-only: no tool here can change the school account,
hand anything in, or mark anything done. The one exception writes nowhere near them — add_note, when it is
listed, saves a file in the user's own notes directory.`;
export function createServer(config: Config, services?: Services): { server: McpServer; context: ServerContext } {
const context = new ServerContext(config, services);
@@ -82,6 +94,8 @@ export function createServer(config: Config, services?: Services): { server: Mcp
registerIndexTools(server, context);
// Only when a key is configured: the tools are not offered at all otherwise.
registerUntisTools(server, context);
// Same rule, for NOTES_DIR.
registerNoteTools(server, context);
registerRawTool(server, context);
registerResources(server, context);
registerPrompts(server, context);

View File

@@ -66,6 +66,9 @@ export function registerIndexTools(server: McpServer, context: ServerContext): v
`- Scope: ${result.scope === 'full' ? 'all courses' : `course ${result.scope}`}`,
`- Generation: ${result.crawlId}`,
`- Courses: ${result.courses}${result.rooms > 0 ? `, rooms: ${result.rooms}` : ''}, files: ${result.files}`,
result.notes > 0 || result.lessons > 0
? `- Own notes: ${result.notes}, class-register lessons: ${result.lessons}`
: undefined,
`- Newly mirrored: ${result.mirrored}, text extracted: ${result.extracted}, skipped: ${result.skipped}`,
`- Took ${(result.durationMs / 1000).toFixed(1)}s`,
result.failures.length > 0
@@ -92,13 +95,15 @@ export function registerIndexTools(server: McpServer, context: ServerContext): v
description:
'Lists boards, cards, files, lessons and tasks that appeared, changed or disappeared since a point in ' +
'time. The Schulcloud API has no "changed since" filter of any kind, so this compares stored crawls — ' +
'meaning it can only see back as far as the index goes. This is the tool for "what is new this week?".',
'meaning it can only see back as far as the index goes. This is the tool for "what is new this week?". ' +
'It also covers the user\'s own notes and the WebUntis class register, so "what has happened since ' +
'Monday" includes the lessons that were logged and the notes that were written.',
inputSchema: {
since: z
.string()
.describe('An ISO date/time, or a generation id from refresh_index. e.g. "2026-09-10".'),
kinds: z
.array(z.enum(['course', 'room', 'board', 'lesson', 'task', 'file']))
.array(z.enum(['course', 'room', 'board', 'lesson', 'task', 'file', 'submission', 'note', 'untis']))
.optional()
.describe('Restrict to certain kinds of thing. Omit for all.'),
limit: z.number().int().min(1).max(200).default(50).describe('Maximum entries per section.'),

253
src/mcp/tools/notes.ts Normal file
View File

@@ -0,0 +1,253 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../../context.ts';
import { germanDay, isCalendarDate, schoolToday } from '../../core/dates.ts';
import {
filterNotes,
NoteNotFound,
readNoteAt,
readNotes,
writeNote,
type NoteDoc,
} from '../../core/notes.ts';
import { heading, joinSections, matchesAll, tokenize } from '../../core/text.ts';
import { failure, text, toToolError } from './result.ts';
/**
* The user's own lesson notes.
*
* Registered only when NOTES_DIR is set, on the same principle as the untis_*
* tools: a note tool with nowhere to read from can only ever fail, and a model
* offered one will keep trying it.
*
* These are the only tools in this server that write anything, and what they
* write is the user's own notes directory — never Schulcloud, which stays
* read-only in the strict sense the invariant in CLAUDE.md describes. The write
* is bounded by the same two functions the file mirror uses: every path
* component is reduced by `safeComponent` and the result is checked by
* `resolveWithin`, so a title of `../../.ssh/authorized_keys` becomes a
* filename and not a path.
*/
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
/** Notes listed before the tool starts summarising instead of listing. */
const MAX_LISTED = 200;
const dateArgument = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD.')
.describe('A date as YYYY-MM-DD.');
export function registerNoteTools(server: McpServer, context: ServerContext): void {
const root = context.config.notesDir;
if (!root) return;
server.registerTool(
'list_notes',
{
title: 'My lesson notes',
description:
"The user's own notes from lessons — what they wrote down themselves, which is neither in Schulcloud " +
'nor in WebUntis and is often the only record of what a teacher actually said. **Read these before ' +
'answering anything about what was covered in class, and before preparing for a test**: they say what ' +
'was emphasised, which the uploaded material does not. Filter by subject ("Deutsch", "LF07") or by ' +
'date to get the lessons around a topic. Returns titles and first lines; get_note opens one. ' +
'search finds notes by their contents as well.',
inputSchema: {
subject: z
.string()
.optional()
.describe('Only notes for this subject, matched as a fragment. The user\'s own wording, not a course id.'),
since: dateArgument.optional().describe('Only notes from this day onwards.'),
until: dateArgument.optional().describe('Only notes up to and including this day.'),
query: z
.string()
.optional()
.describe('Only notes whose title, subject or tags contain every word given. For full text, use search.'),
limit: z.number().int().min(1).max(MAX_LISTED).default(50).describe('Maximum notes to list.'),
},
annotations: READ_ONLY,
},
async ({ subject, since, until, query, limit }) => {
const unreal = [since, until].filter((value): value is string => Boolean(value) && !isCalendarDate(value!));
if (unreal.length > 0) return failure(`Not a date in the calendar: ${unreal.join(', ')}. Use YYYY-MM-DD.`);
try {
const all = await readNotes(root);
if (all.length === 0) return text(emptyStore(root, context.config.notesWritable));
const terms = query ? tokenize(query) : [];
const matched = filterNotes(all, { subject, since, until }).filter(
(note) => terms.length === 0 || matchesAll([note.title, note.subject, note.tags.join(' ')].join(' '), terms),
);
if (matched.length === 0) {
return text(
`None of the ${all.length} note(s) match${describeFilter({ subject, since, until, query })}. ` +
'Drop a filter, or use search to look inside the text.',
);
}
return text(
joinSections([
heading(2, `Notizen (${Math.min(limit, matched.length)} of ${matched.length})`),
matched.slice(0, limit).map(listLine).join('\n'),
matched.length > limit ? `_${matched.length - limit} more — narrow it down with subject or since._` : undefined,
]),
);
} catch (error) {
return toToolError(error, 'read the notes directory');
}
},
);
server.registerTool(
'get_note',
{
title: 'Read one note',
description:
'The full text of one of the user\'s own notes, by the path list_notes and search print. Quote from it ' +
'the way you would quote a course file — it is a primary source for what happened in the lesson.',
inputSchema: {
path: z
.string()
.min(1)
.describe('The note\'s path, e.g. "Deutsch/2026-09-15 Erörterung.md", exactly as it was listed.'),
},
annotations: READ_ONLY,
},
async ({ path }) => {
try {
return text(renderNote(await readNoteAt(root, path)));
} catch (error) {
if (error instanceof NoteNotFound) {
return failure(
`There is no note at "${path}". Paths come from list_notes or search and include the folder and ` +
'the .md ending.',
);
}
return toToolError(error, `read the note "${path}"`);
}
},
);
if (!context.config.notesWritable) return;
server.registerTool(
'add_note',
{
title: 'Write a lesson note',
description:
'Saves a note into the user\'s own notes, so it is there next time — during a lesson ("halte fest, ' +
'dass …"), or when writing up what was just discussed. Give the subject as the user says it ' +
'("Deutsch", "LF07") and the day the lesson was on; both are what makes the note findable later. ' +
'Pass append=true to add to the note already written for that subject and day rather than starting a ' +
'second one — that is the right choice during a lesson. This writes **only** to the notes directory; ' +
'it cannot change anything in Schulcloud or WebUntis. Do not use it to store things the user did not ' +
'ask to keep.',
inputSchema: {
title: z.string().min(1).max(200).describe('A short title — the topic of the lesson, not a sentence.'),
text: z.string().min(1).describe('The note itself, as Markdown. Write it in the language the user used.'),
subject: z
.string()
.max(80)
.optional()
.describe('Subject as the user names it, e.g. "Deutsch" or "LF07". Becomes the folder.'),
date: dateArgument.optional().describe('The day of the lesson. Defaults to today.'),
tags: z.array(z.string().max(40)).max(12).optional().describe('Optional keywords, e.g. ["klausur"].'),
courseId: z
.string()
.optional()
.describe('The Schulcloud course id, when it is known — it links the note to the course in search.'),
append: z
.boolean()
.default(false)
.describe('Add to an existing note for that subject and day instead of creating another one.'),
},
// Writes — to the notes directory, and to nothing else.
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
},
async ({ title, text: body, subject, date, tags, courseId, append }) => {
if (date && !isCalendarDate(date)) return failure(`Not a date in the calendar: ${date}. Use YYYY-MM-DD.`);
try {
const { note, appended } = await writeNote(root, {
title,
text: body,
date: date ?? schoolToday(),
...(subject ? { subject } : {}),
...(courseId ? { courseId } : {}),
...(tags && tags.length > 0 ? { tags } : {}),
source: 'add_note',
append,
});
return text(
joinSections([
`${appended ? 'Added to' : 'Saved'} **${note.title}** — \`${note.path}\``,
'_It is searchable after the next refresh_index; get_note reads it now._',
]),
);
} catch (error) {
return toToolError(error, `save the note "${title}"`);
}
},
);
}
// --- formatting ----------------------------------------------------------
function listLine(note: NoteDoc): string {
const when = note.date ? germanDay(note.date) : 'ohne Datum';
const where = note.subject ? ` · ${note.subject}` : '';
const tags = note.tags.length > 0 ? ` · ${note.tags.map((tag) => `#${tag}`).join(' ')}` : '';
const first = firstLine(note.text);
return [`- **${note.title}** — ${when}${where}${tags} \`${note.path}\``, first ? ` ${first}` : undefined]
.filter(Boolean)
.join('\n');
}
function renderNote(note: NoteDoc): string {
const facts = [
note.date ? `Datum: ${germanDay(note.date)}` : undefined,
note.subject ? `Fach: ${note.subject}` : undefined,
note.tags.length > 0 ? `Tags: ${note.tags.join(', ')}` : undefined,
note.courseId ? `Kurs: \`${note.courseId}\`` : undefined,
].filter(Boolean);
return joinSections([
heading(2, note.title),
facts.length > 0 ? `_${facts.join(' · ')}_` : undefined,
note.text || '_This note is empty._',
`_Own note: \`${note.path}\`_`,
]);
}
function firstLine(body: string): string | undefined {
const line = body
.split('\n')
.map((entry) => entry.replace(/^#+\s*/, '').trim())
.find((entry) => entry.length > 0);
if (!line) return undefined;
return line.length > 160 ? `${line.slice(0, 157)}` : line;
}
function describeFilter(filter: { subject?: string; since?: string; until?: string; query?: string }): string {
const parts = [
filter.subject ? `subject "${filter.subject}"` : undefined,
filter.query ? `"${filter.query}"` : undefined,
filter.since ? `from ${germanDay(filter.since)}` : undefined,
filter.until ? `to ${germanDay(filter.until)}` : undefined,
].filter(Boolean);
return parts.length > 0 ? ` ${parts.join(', ')}` : '';
}
/**
* The empty case, which is the normal one on a fresh install.
*
* It says where the directory is because the usual next step is to put files
* there by hand or with the import script, not to call a tool.
*/
function emptyStore(root: string, writable: boolean): string {
return joinSections([
`There are no notes yet. The notes directory is \`${root}\`.`,
writable
? 'Notes are Markdown files; add_note writes one, and anything dropped in that directory is picked up too.'
: 'This server was started with NOTES_READONLY, so notes have to be put there by hand or synced in.',
]);
}

View File

@@ -19,6 +19,10 @@ const TOOL_FOR: Record<string, string> = {
// A submission is reached through its task, not by an id of its own: there
// is no get_submission because the API has no route to one.
submission: 'get_task',
note: 'get_note',
// A class-register hit is followed up by its series, not by the single
// period: untis_lesson_topics with that periodId returns the lessons around it.
untis: 'untis_lesson_topics',
};
export function registerSearchTool(server: McpServer, context: ServerContext): void {
@@ -31,14 +35,20 @@ export function registerSearchTool(server: McpServer, context: ServerContext): v
'names — and, unlike anything else here, **the text inside PDFs, Word, PowerPoint and OpenDocument ' +
'files**. Use it whenever the user names a topic rather than a course ("where is the stuff about ' +
'encryption?"). Matching is case- and accent-insensitive and understands German word forms. ' +
'It covers three sources at once: the Schulcloud material, **the user\'s own lesson notes** and ' +
'**the WebUntis class register** — so one query answers "what do we have on this, what did I write ' +
'down, and when did we do it". Restrict with kinds to just one of them. ' +
'Results come from a local index; if they look stale, refresh_index re-reads Schulcloud.',
inputSchema: {
query: z.string().min(2).describe('What to look for. German and English both work.'),
courseId: z.string().optional().describe('Restrict the search to a single course.'),
kinds: z
.array(z.enum(['course', 'room', 'board', 'lesson', 'task', 'file']))
.array(z.enum(['course', 'room', 'board', 'lesson', 'task', 'file', 'submission', 'note', 'untis']))
.optional()
.describe('Restrict to certain kinds of thing, e.g. ["file"] to find documents only.'),
.describe(
'Restrict to certain kinds of thing: ["file"] for documents only, ["note"] for the user\'s own ' +
'notes, ["untis"] for what the class register says was taught.',
),
limit: z.number().int().min(1).max(100).default(30).describe('Maximum number of hits to return.'),
fresh: z
.boolean()
@@ -117,6 +127,9 @@ async function liveSearch(
// Same trade as files: worth two extra requests per pad when the caller
// named a course, too slow to do across every course they can see.
config: scoped ? context.config : undefined,
// Notes are local files, so the live path can afford them and must: a
// fresh search that silently dropped them would disagree with the index.
...(context.config.notesDir ? { notesDir: context.config.notesDir } : {}),
});
const hits = searchSnapshot(snapshot, query, limit);
@@ -153,6 +166,12 @@ function nextStep(hit: SearchResult): string {
? `\`fs_read\` with path \`${fsPath}\``
: `\`fs_read\` with fileId \`${hit.nodeId}\` and name \`${hit.title}\``;
}
// A note is addressed by its path, not by an id.
if (hit.kind === 'note') return `\`get_note\` with path \`${hit.nodeId}\``;
if (hit.kind === 'untis') {
const periodId = hit.meta?.periodId;
return `\`untis_lesson_topics\` with periodId \`${typeof periodId === 'number' ? periodId : hit.nodeId}\``;
}
// A submission has no id of its own that any tool takes: get_task is
// reached through the *task*, so point at that rather than at the
// submission id, which would simply 404.
@@ -167,11 +186,25 @@ function fileManagerPlace(hit: SearchResult): string {
return known.area === 'courses' && hit.courseTitle ? `${known.label}, ${hit.courseTitle}` : known.label;
}
/** What kind of thing a hit is, in words rather than in the store's vocabulary. */
function placeOf(hit: SearchResult): string {
if (hit.kind === 'file' && hit.meta?.source === 'file-manager') return `file in ${fileManagerPlace(hit)}`;
if (hit.kind === 'note') {
const date = typeof hit.meta?.date === 'string' ? formatDate(hit.meta.date) : undefined;
const subject = typeof hit.meta?.subject === 'string' ? hit.meta.subject : undefined;
// Named as the user's own writing, so it is never quoted as if the school
// had published it.
return ['my own note', subject, date].filter(Boolean).join(', ');
}
if (hit.kind === 'untis') {
const date = typeof hit.meta?.date === 'string' ? formatDate(hit.meta.date) : undefined;
return ['class register (WebUntis)', date].filter(Boolean).join(', ');
}
return `${hit.kind} in ${hit.courseTitle || hit.path}`;
}
function formatIndexed(hit: SearchResult): string {
const where =
hit.kind === 'file' && hit.meta?.source === 'file-manager'
? `file in ${fileManagerPlace(hit)}`
: `${hit.kind} in ${hit.courseTitle || hit.path}`;
const where = placeOf(hit);
return [
`- **${hit.title}** — ${where}`,
hit.snippet && hit.snippet !== hit.title ? ` ${hit.snippet}` : undefined,
@@ -182,11 +215,12 @@ function formatIndexed(hit: SearchResult): string {
}
function formatLive(hit: Hit): string {
return [
`- **${hit.courseTitle}** — ${hit.where}`,
` ${hit.snippet}`,
`\`${TOOL_FOR[hit.targetKind]}\` with id \`${hit.targetId}\``,
].join('\n');
// A note is addressed by path; everything else by id.
const next =
hit.targetKind === 'note'
? `\`get_note\` with path \`${hit.targetId}\``
: `\`${TOOL_FOR[hit.targetKind]}\` with id \`${hit.targetId}\``;
return [`- **${hit.courseTitle}** — ${hit.where}`, ` ${hit.snippet}`, next].join('\n');
}
function freshness(crawledAt: string | undefined): string {

View File

@@ -17,6 +17,7 @@ import {
type UntisHomework,
type UntisLesson,
} from '../../core/untis.ts';
import { collectLessonLog, type LessonLogEntry } from '../../core/untis-history.ts';
import { failure, text, toToolError } from './result.ts';
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
@@ -33,6 +34,20 @@ const LOOKAHEAD_DAYS = 14;
*/
const MAX_RANGE_DAYS = 92;
/** How far back a subject's class register is read when no range is given. */
const DEFAULT_HISTORY_DAYS = 120;
/**
* Longest class-register range.
*
* Wider than the timetable's limit on purpose — the point of the subject form
* is to cover a term or a year, and the payload is one line per lesson that
* recorded something, not per period. It still needs a ceiling: the range is
* fetched in 90-day windows plus a call per lesson series, so "since 2019"
* would be a few hundred requests against a server that rate-limits.
*/
const MAX_HISTORY_DAYS = 400;
const dateArgument = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD.')
@@ -137,44 +152,126 @@ export function registerUntisTools(server: McpServer, context: ServerContext): v
{
title: 'What was taught (WebUntis)',
description:
'The class register\'s record of what previous lessons of one series actually covered ' +
'("Unterrichtsinhalt"), newest first. Use it to prepare for the next lesson of a subject: pass the ' +
'period id of an upcoming lesson from untis_timetable and it answers "where did we get to". Says ' +
'nothing about material or homework — that is Schulcloud and untis_homework.',
'The class register\'s record of what lessons actually covered ("Unterrichtsinhalt"), newest first — ' +
'the teacher\'s own account of each lesson, which exists nowhere in Schulcloud. Two ways in: pass a ' +
'**subject** ("Deutsch", "LF07") to read back over a whole term, which is how to reconstruct what a ' +
'course has done and what a test will cover; or pass the **periodId** of one upcoming lesson from ' +
'untis_timetable to answer "where did we get to" for that series. With a subject it also returns the ' +
'notes teachers left on those lessons and the homework they set. Says nothing about the material ' +
'itself — that is Schulcloud — and nothing about what the user wrote down, which is list_notes.',
inputSchema: {
subject: z
.string()
.optional()
.describe('Subject name or code, matched as a fragment against both, e.g. "Deutsch" or "LF07".'),
periodId: z
.number()
.int()
.describe('The period id of a lesson, as untis_timetable prints it in backticks.'),
limit: z.number().int().min(1).max(50).default(10).describe('How many previous lessons to list.'),
.optional()
.describe('The period id of one lesson, as untis_timetable prints it in backticks. Covers that series only.'),
from: dateArgument.optional().describe(`With a subject: earliest day. Defaults to ${DEFAULT_HISTORY_DAYS} days back.`),
to: dateArgument.optional().describe('With a subject: latest day. Defaults to today.'),
limit: z.number().int().min(1).max(100).default(20).describe('How many lessons to list.'),
},
annotations: READ_ONLY,
},
async ({ periodId, limit }) => {
try {
const topics = await untis.lessonTopics(periodId);
if (topics.length === 0) {
async ({ subject, periodId, from, to, limit }) => {
if (subject === undefined && periodId === undefined) {
return failure(
'Give either a subject ("Deutsch") to read a whole term of the class register, or the periodId of ' +
'one lesson from untis_timetable to read just its series.',
);
}
if (subject !== undefined && periodId !== undefined) {
return failure('Give a subject or a periodId, not both: they are two different ways of choosing lessons.');
}
if (periodId !== undefined) {
try {
const topics = await untis.lessonTopics(periodId);
if (topics.length === 0) {
return text(
`No lesson contents recorded for period ${periodId}. Either the class register is empty for this ` +
'series or the teacher does not fill it in. Try the subject instead — another series of the same ' +
'subject may be filled in.',
);
}
return text(
`No lesson contents recorded for period ${periodId}. Either the class register is empty for this ` +
'series or the teacher does not fill it in.',
joinSections([
heading(2, `Unterrichtsinhalte (${Math.min(limit, topics.length)} of ${topics.length})`),
topics
.slice(0, limit)
.map((topic) => `- ${germanDay(topic.date)} ${topic.start}${topic.end}: ${topic.text}`)
.join('\n'),
]),
);
} catch (error) {
return untisError(error, `read what was taught before period ${periodId}`);
}
}
const end = to ?? schoolToday();
const start = from ?? addDays(end, -DEFAULT_HISTORY_DAYS);
const unreal = [...new Set([start, end])].filter((value) => !isCalendarDate(value));
if (unreal.length > 0) return failure(`Not a date in the calendar: ${unreal.join(', ')}. Use YYYY-MM-DD.`);
if (end < start) return failure(`The range ends before it starts: ${start} to ${end}.`);
if (daysBetween(start, end) > MAX_HISTORY_DAYS) {
return failure(
`That is ${daysBetween(start, end)} days of class register. Ask for at most ${MAX_HISTORY_DAYS}` +
'a longer range is fetched in 90-day windows plus a call per lesson series.',
);
}
try {
const log = await collectLessonLog(untis, { from: start, to: end, subject: subject! });
if (log.periodsSeen === 0) {
return text(
`No lessons of "${subject}" between ${germanDay(start)} and ${germanDay(end)}. Check the subject ` +
'against untis_timetable — the register uses the school\'s own codes.',
);
}
if (log.entries.length === 0) {
return text(
`${log.periodsSeen} lesson(s) of "${subject}" took place between ${germanDay(start)} and ` +
`${germanDay(end)}, but nothing was recorded for any of them — this teacher does not fill in the ` +
'class register. The material in Schulcloud is then the only record; try get_course or search.',
);
}
return text(
joinSections([
heading(2, `Unterrichtsinhalte (${Math.min(limit, topics.length)} of ${topics.length})`),
topics
.slice(0, limit)
.map((topic) => `- ${germanDay(topic.date)} ${topic.start}${topic.end}: ${topic.text}`)
.join('\n'),
heading(2, `Unterricht${subject}“ — ${germanDay(start)} bis ${germanDay(end)}`),
`_${log.entries.length} of ${log.periodsSeen} lesson(s) have an entry in the class register._`,
log.entries.slice(0, limit).map(formatLogEntry).join('\n'),
log.entries.length > limit
? `_${log.entries.length - limit} older lesson(s) not shown — raise limit or narrow the range._`
: undefined,
log.failures.length > 0
? `_${log.failures.length} lesson series could not be read, so some entries may be missing._`
: undefined,
]),
);
} catch (error) {
return untisError(error, `read what was taught before period ${periodId}`);
return untisError(error, `read the class register for "${subject}"`);
}
},
);
}
/** One class-register entry: the topic, what the teacher noted, and what was set. */
function formatLogEntry(entry: LessonLogEntry): string {
const teachers = entry.teachers.length > 0 ? ` · ${entry.teachers.join(', ')}` : '';
const extra = [
entry.notes.info,
entry.notes.lesson,
entry.notes.substitution ? `Vertretungstext: ${entry.notes.substitution}` : undefined,
entry.exam ? `**Prüfung:** ${entry.exam}` : undefined,
...entry.homework.map((item) => `Hausaufgabe bis ${germanDay(item.due)}: ${item.text}`),
].filter((value): value is string => Boolean(value));
const head = `- **${germanDay(entry.date)}** ${entry.start}${entry.end}${teachers} \`${entry.periodId}\`` +
`${entry.topic ? `: ${entry.topic}` : ''}`;
return extra.length > 0 ? `${head}\n${extra.map((line) => ` - ${line}`).join('\n')}` : head;
}
/**
* The timetable for a range as Markdown: what `untis_timetable` returns, and
* what the Tagesvorbereitung prompt attaches, so an attached day reads exactly

View File

@@ -50,8 +50,10 @@ export async function createServices(config: Config): Promise<Services> {
const files = new FileManager(client);
const store = await Store.open(config.databaseUrl);
const indexer = store ? new Indexer(client, store, config) : undefined;
const untis = config.untis ? new UntisClient(config.untis, config.requestTimeoutMs) : undefined;
// The indexer gets the same client, so the class register is read with the
// master data the untis_* tools have already paid for.
const indexer = store ? new Indexer(client, store, config, { untis }) : undefined;
if (!store) {
console.warn(

View File

@@ -1,6 +1,8 @@
import { createHash } from 'node:crypto';
import type { CrawledFile, Snapshot } from '../core/crawl.ts';
import { noteSearchText } from '../core/notes.ts';
import { mirrorPath } from '../core/paths.ts';
import { lessonLogText } from '../core/untis-history.ts';
import { connect, migrate, type Db } from './db.ts';
/**
@@ -14,7 +16,23 @@ import { connect, migrate, type Db } from './db.ts';
* Identity diffing also gives deletions for free, which no timestamp scheme can.
*/
export type NodeKind = 'course' | 'room' | 'board' | 'lesson' | 'task' | 'file' | 'submission';
/**
* `note` and `untis` are not Schulcloud's: the first is what the user wrote
* down, the second is the WebUntis class register. They live in the same table
* because the question they answer is the same one — "where is the material
* about X" — and a search that made the user choose which of three systems to
* look in would be answering a question nobody asked.
*/
export type NodeKind =
| 'course'
| 'room'
| 'board'
| 'lesson'
| 'task'
| 'file'
| 'submission'
| 'note'
| 'untis';
export interface StoredNode {
kind: NodeKind;
@@ -632,6 +650,57 @@ export function snapshotToNodes(snapshot: Snapshot): StoredNode[] {
}
}
// The user's own notes. `courseId` is set only when the note names one, so
// most notes sit outside any course — which is also what makes them survive
// a per-course crawl's carry-forward untouched.
for (const note of snapshot.notes ?? []) {
nodes.push({
kind: 'note',
nodeId: note.path,
courseId: note.courseId ?? null,
title: note.title,
body: noteSearchText(note),
path: `Notizen/${note.path}`,
meta: {
...(note.date ? { date: note.date } : {}),
...(note.subject ? { subject: note.subject } : {}),
...(note.source ? { source: note.source } : {}),
tags: note.tags,
modifiedAt: note.modifiedAt,
bytes: note.bytes,
},
// The file's mtime is deliberately not in the digest: a sync tool that
// rewrites a file byte-for-byte must not show up as a changed note.
digest: digestOf([note.title, note.text, note.subject ?? '', note.date ?? '', note.tags]),
});
}
// The class register. The period id is the identity: it is stable, and it is
// what untis_lesson_topics takes, so a search hit can be followed up.
for (const entry of snapshot.lessonLog ?? []) {
const subject = entry.subject ?? 'Unterricht';
nodes.push({
kind: 'untis',
nodeId: `period-${entry.periodId}`,
courseId: null,
title: `${subject}${entry.date}`,
body: lessonLogText(entry),
path: `Klassenbuch/${subject}/${entry.date}`,
meta: {
periodId: entry.periodId,
lessonId: entry.lessonId,
date: entry.date,
start: entry.start,
end: entry.end,
...(entry.subject ? { subject: entry.subject } : {}),
...(entry.subjectLong ? { subjectLong: entry.subjectLong } : {}),
teachers: entry.teachers,
...(entry.exam ? { exam: entry.exam } : {}),
},
digest: digestOf([entry.topic ?? '', entry.notes, entry.exam ?? '', entry.homework]),
});
}
for (const file of snapshot.files) {
nodes.push(fileNode(file));
}

100
test/apple-notes.test.ts Normal file
View File

@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { convertAppleNote, htmlToMarkdown, parseExport } from '../src/cli/apple-notes.ts';
/**
* The migration path from Notes.app. The HTML here is the shape Notes actually
* emits — divs for lines, a repeated title, `<object>` for attachments — since
* the converter's whole job is to survive that particular markup.
*/
describe('htmlToMarkdown', () => {
it('turns divs and breaks into lines', () => {
assert.equal(htmlToMarkdown('<div>Erste Zeile</div><div>Zweite<br>Dritte</div>'), 'Erste Zeile\nZweite\nDritte');
});
it('keeps headings, lists and emphasis', () => {
const markdown = htmlToMarkdown('<h1>Thema</h1><ul><li>eins</li><li><b>zwei</b></li></ul>');
assert.match(markdown, /^# Thema$/m);
assert.match(markdown, /^- eins$/m);
assert.match(markdown, /^- \*\*zwei\*\*$/m);
});
it('keeps bullets together and separates what follows the list', () => {
// Blank lines between bullets make a loose list; no blank line after one
// makes the next paragraph a lazy continuation of the last bullet.
const markdown = htmlToMarkdown('<ul><li>eins</li><li>zwei</li></ul><div>danach</div>');
assert.equal(markdown, '- eins\n- zwei\n\ndanach');
});
it('renders a checklist as a task list', () => {
assert.match(htmlToMarkdown('<ul><li checked="checked">erledigt</li></ul>'), /- \[x\] erledigt/);
});
it('keeps a link as a link', () => {
assert.equal(htmlToMarkdown('<div><a href="https://example.org">Quelle</a></div>'), '[Quelle](https://example.org)');
});
it('decodes entities', () => {
assert.equal(htmlToMarkdown('<div>Er&ouml;rterung &amp; Analyse</div>'), 'Erörterung & Analyse');
});
it('says an attachment was there rather than dropping it silently', () => {
// A note that was one scan would otherwise import as empty, and nobody
// would know the picture had been left behind.
assert.match(htmlToMarkdown('<div>Tafelbild</div><object data="x"></object>'), /Anhang aus Apple Notes/);
});
it('emits no empty emphasis markers', () => {
assert.equal(htmlToMarkdown('<div><b> </b>Text</div>'), 'Text');
});
});
describe('convertAppleNote', () => {
const note = {
id: 'x-coredata://1',
name: 'Erörterung',
body: '<div><b>Erörterung</b></div><div>These, Argument, Fazit</div>',
folder: 'Schule/Deutsch',
created: '2026-09-15T08:30:00',
modified: '2026-09-20T19:00:00',
};
it('dates the note when it was written, not when it was last touched', () => {
// The creation date is the lesson; the modification date is whenever it
// was last tidied, which is not a school day at all.
assert.equal(convertAppleNote(note).date, '2026-09-15');
});
it('takes the leaf of the Notes folder as the subject', () => {
assert.equal(convertAppleNote(note).subject, 'Deutsch');
});
it('ignores Notes\' own default folders', () => {
assert.equal(convertAppleNote({ ...note, folder: 'Notizen' }).subject, undefined);
});
it('lets an explicit subject win', () => {
assert.equal(convertAppleNote(note, { subject: 'LF07' }).subject, 'LF07');
});
it('does not repeat the title as the first line of the body', () => {
const converted = convertAppleNote(note);
assert.equal(converted.title, 'Erörterung');
assert.equal(converted.text, 'These, Argument, Fazit');
});
it('marks where it came from', () => {
assert.equal(convertAppleNote(note).source, 'apple-notes');
});
});
describe('parseExport', () => {
it('reads one note per line and ignores blank lines', () => {
assert.equal(parseExport('{"id":"1","name":"A","body":"<div>a</div>"}\n\n{"id":"2","name":"B","body":""}\n').length, 2);
});
it('names the line it could not read', () => {
assert.throws(() => parseExport('{"id":"1"}\nnope\n'), /Line 2/);
});
});

221
test/notes.test.ts Normal file
View File

@@ -0,0 +1,221 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, writeFile, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it } from 'node:test';
import {
filterNotes,
NoteNotFound,
notePathFor,
parseNote,
readNoteAt,
readNotes,
renderNote,
splitFrontmatter,
writeNote,
} from '../src/core/notes.ts';
const STAMP = { modifiedAt: '2026-09-15T10:00:00.000Z', bytes: 100 };
async function root(): Promise<string> {
return mkdtemp(join(tmpdir(), 'schulcloud-notes-'));
}
describe('splitFrontmatter', () => {
it('reads a leading block and keeps the body', () => {
const { front, body } = splitFrontmatter('---\ntitle: Erörterung\ndate: 2026-09-15\n---\n\nText hier.\n');
assert.equal(front.title, 'Erörterung');
assert.equal(front.date, '2026-09-15');
assert.equal(body.trim(), 'Text hier.');
});
it('leaves a note that merely starts with a rule alone', () => {
// A horizontal rule with no closing fence must not eat the note.
const { front, body } = splitFrontmatter('---\nkein Frontmatter, nur ein Strich\n');
assert.deepEqual(front, {});
assert.match(body, /kein Frontmatter/);
});
it('accepts a note with no frontmatter at all', () => {
const { front, body } = splitFrontmatter('# Titel\n\nText.');
assert.deepEqual(front, {});
assert.equal(body, '# Titel\n\nText.');
});
it('takes a German date and an inline tag list', () => {
const { front } = splitFrontmatter('---\ndate: 15.09.2026\ntags: [klausur, "aufsatz"]\nfach: Deutsch\n---\nx');
assert.equal(front.date, '2026-09-15');
assert.deepEqual(front.tags, ['klausur', 'aufsatz']);
// "fach" is the German spelling of subject and has to mean the same thing.
assert.equal(front.subject, 'Deutsch');
});
it('keeps unknown keys rather than dropping them', () => {
const { front } = splitFrontmatter('---\ntitle: T\nlehrer: Frau Meier\n---\nx');
assert.equal(front.extra?.lehrer, 'Frau Meier');
});
});
describe('parseNote', () => {
it('falls back to the heading, then to the filename, for a title', () => {
assert.equal(parseNote('a.md', '# Kryptografie\n\nText', STAMP).title, 'Kryptografie');
assert.equal(parseNote('Deutsch/2026-09-15 Erörterung.md', 'nur Text', STAMP).title, 'Erörterung');
});
it('takes the date from the filename when the frontmatter has none', () => {
assert.equal(parseNote('Deutsch/2026-09-15 Erörterung.md', 'x', STAMP).date, '2026-09-15');
});
it('never dates a note from its mtime', () => {
// An import writes every file today; dating a year of lessons "today"
// would make the whole store useless for revision.
assert.equal(parseNote('lose Notiz.md', 'x', STAMP).date, undefined);
});
it('takes the folder as the subject', () => {
assert.equal(parseNote('LF07/2026-09-15 Netze.md', 'x', STAMP).subject, 'LF07');
assert.equal(parseNote('lose.md', 'x', STAMP).subject, undefined);
});
it('round-trips through renderNote', () => {
const rendered = renderNote({ title: 'Erörterung', date: '2026-09-15', subject: 'Deutsch', tags: ['klausur'] }, 'Body');
const note = parseNote('Deutsch/x.md', rendered, STAMP);
assert.equal(note.title, 'Erörterung');
assert.equal(note.date, '2026-09-15');
assert.equal(note.subject, 'Deutsch');
assert.deepEqual(note.tags, ['klausur']);
assert.equal(note.text, 'Body');
});
});
describe('notePathFor', () => {
it('is subject then date then title', () => {
assert.equal(notePathFor({ date: '2026-09-15', subject: 'Deutsch', title: 'Erörterung' }), 'Deutsch/2026-09-15 Erörterung.md');
});
it('reduces a hostile title to one component', () => {
// The title comes from a tool call, so it is untrusted input that becomes
// a filename — the same boundary the file mirror has.
const path = notePathFor({ date: '2026-09-15', subject: '../../etc', title: '../../.ssh/authorized_keys' });
assert.equal(path.split('/').length, 2);
assert.ok(!path.includes('..'), path);
});
});
describe('readNotes', () => {
it('is empty, not an error, for a directory that does not exist', async () => {
assert.deepEqual(await readNotes(join(tmpdir(), 'schulcloud-notes-absent-xyz')), []);
});
it('walks folders, skips dotfiles and non-notes, and sorts newest first', async () => {
const dir = await root();
await mkdir(join(dir, 'Deutsch'), { recursive: true });
await mkdir(join(dir, '.obsidian'), { recursive: true });
await writeFile(join(dir, 'Deutsch', '2026-09-15 Erörterung.md'), 'A');
await writeFile(join(dir, 'Deutsch', '2026-09-22 Analyse.md'), 'B');
await writeFile(join(dir, '.obsidian', 'workspace.md'), 'nope');
await writeFile(join(dir, 'bild.png'), 'nope');
const notes = await readNotes(dir);
assert.deepEqual(notes.map((note) => note.title), ['Analyse', 'Erörterung']);
});
it('refuses to read its way out of the root', async () => {
const dir = await root();
await assert.rejects(() => readNoteAt(dir, '../../etc/passwd'), /traversal/);
});
it('reports a missing note as missing', async () => {
const dir = await root();
await assert.rejects(() => readNoteAt(dir, 'Deutsch/nichts.md'), NoteNotFound);
});
});
describe('writeNote', () => {
it('creates a note with frontmatter at the derived path', async () => {
const dir = await root();
const { note } = await writeNote(dir, { title: 'Erörterung', text: 'Aufbau: These, Argument, Fazit.', subject: 'Deutsch', date: '2026-09-15' });
assert.equal(note.path, 'Deutsch/2026-09-15 Erörterung.md');
assert.equal(note.subject, 'Deutsch');
assert.match(await readFile(join(dir, note.path), 'utf8'), /^---\ntitle: Erörterung\n/);
});
it('appends to the same file when asked, so a lesson stays one note', async () => {
const dir = await root();
await writeNote(dir, { title: 'Erörterung', text: 'Erstens.', subject: 'Deutsch', date: '2026-09-15' });
const { note, appended } = await writeNote(dir, {
title: 'Nachtrag', text: 'Zweitens.', subject: 'Deutsch', date: '2026-09-15',
path: 'Deutsch/2026-09-15 Erörterung.md', append: true,
});
assert.equal(appended, true);
assert.match(note.text, /Erstens\./);
assert.match(note.text, /Zweitens\./);
assert.equal((await readNotes(dir)).length, 1);
});
it('appends by lesson, not by title — a second note in the same lesson has another name', async () => {
// "halt das auch noch fest" mid-lesson carries a new title; deriving the
// path from it would start a second note every time, which is the one
// thing append exists to prevent.
const dir = await root();
await writeNote(dir, { title: 'Erörterung', text: 'Erstens.', subject: 'Deutsch', date: '2026-09-15' });
const { note, appended } = await writeNote(dir, {
title: 'Nachtrag', text: 'Zweitens.', subject: 'Deutsch', date: '2026-09-15', append: true,
});
assert.equal(appended, true);
assert.equal(note.path, 'Deutsch/2026-09-15 Erörterung.md');
assert.equal((await readNotes(dir)).length, 1);
});
it('creates the note when append finds nothing to append to', async () => {
const dir = await root();
const { note, appended } = await writeNote(dir, { title: 'Erstes', text: 'x', subject: 'Deutsch', date: '2026-09-15', append: true });
assert.equal(appended, false);
assert.equal(note.path, 'Deutsch/2026-09-15 Erstes.md');
});
it('does not append across days or subjects', async () => {
const dir = await root();
await writeNote(dir, { title: 'Montag', text: 'a', subject: 'Deutsch', date: '2026-09-15' });
const otherDay = await writeNote(dir, { title: 'Dienstag', text: 'b', subject: 'Deutsch', date: '2026-09-16', append: true });
const otherSubject = await writeNote(dir, { title: 'Netze', text: 'c', subject: 'LF07', date: '2026-09-15', append: true });
assert.equal(otherDay.appended, false);
assert.equal(otherSubject.appended, false);
assert.equal((await readNotes(dir)).length, 3);
});
it('never overwrites: a second note of the same name gets its own file', async () => {
const dir = await root();
await writeNote(dir, { title: 'Test', text: 'eins', subject: 'Deutsch', date: '2026-09-15' });
const { note, appended } = await writeNote(dir, { title: 'Test', text: 'zwei', subject: 'Deutsch', date: '2026-09-15' });
assert.equal(appended, false);
assert.equal(note.path, 'Deutsch/2026-09-15 Test 2.md');
assert.equal((await readNotes(dir)).length, 2);
});
it('cannot be steered out of the notes root by its title', async () => {
const dir = await root();
const { note } = await writeNote(dir, { title: '../../escape', text: 'x', subject: '..', date: '2026-09-15' });
assert.ok(!note.path.includes('..'), note.path);
assert.equal((await readNotes(dir)).length, 1);
});
});
describe('filterNotes', () => {
const notes = [
parseNote('Deutsch/2026-09-15 A.md', 'a', STAMP),
parseNote('LF07/2026-09-22 B.md', 'b', STAMP),
parseNote('lose.md', 'c', STAMP),
];
it('matches a subject as a fragment', () => {
assert.deepEqual(filterNotes(notes, { subject: 'deut' }).map((note) => note.title), ['A']);
});
it('keeps undated notes inside a date window rather than hiding them', () => {
// Excluding them would silently drop every note that arrived without a
// date, which is most of an Apple Notes import.
const titles = filterNotes(notes, { since: '2026-09-20' }).map((note) => note.title);
assert.deepEqual(titles, ['B', 'lose']);
});
});

View File

@@ -29,11 +29,24 @@ function assertDisposable(url: string): void {
function snapshot(
courses: { id: string; title: string; boardText?: string; files?: { id: string; name: string; size: number }[] }[],
rooms: { id: string; name: string; boardText?: string }[] = [],
notes: { path: string; title: string; text: string; subject?: string; date?: string }[] = [],
): Snapshot {
return {
crawledAt: new Date(),
schoolId: 'school1',
failures: [],
submissions: [],
lessonLog: [],
notes: notes.map((n) => ({
path: n.path,
title: n.title,
text: n.text,
...(n.subject ? { subject: n.subject } : {}),
...(n.date ? { date: n.date } : {}),
tags: [],
modifiedAt: '2026-09-15T10:00:00.000Z',
bytes: n.text.length,
})),
rooms: rooms.map((r) => ({
id: r.id,
name: r.name,
@@ -152,6 +165,45 @@ describe('Store', { skip: DB_URL ? false : 'set TEST_DATABASE_URL to run' }, ()
assert.ok(diff.changed.some((n) => n.nodeId === 'r1' && n.kind === 'room'), 'a renamed room is reported as changed');
});
it('indexes the user\'s own notes beside the course material', async () => {
// The point of the notes store: one search covers what the school
// uploaded and what the user wrote down in the lesson.
const before = await store.saveSnapshot(
snapshot(
[{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }],
[],
[{ path: 'Deutsch/2026-09-15 Erörterung.md', title: 'Erörterung', subject: 'Deutsch', date: '2026-09-15', text: 'These, Argument, Fazit. Frau Meier betont den Schluss.' }],
),
'full',
);
const hits = await store.search('Erörterung', { limit: 5 });
const note = hits.find((hit) => hit.kind === 'note');
assert.ok(note, 'a note is searchable');
assert.equal(note.nodeId, 'Deutsch/2026-09-15 Erörterung.md', 'the path is the id get_note takes');
assert.equal(note.meta?.subject, 'Deutsch');
// And an edited note is a change, so what_changed reports it.
const after = await store.saveSnapshot(
snapshot(
[{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }],
[],
[{ path: 'Deutsch/2026-09-15 Erörterung.md', title: 'Erörterung', subject: 'Deutsch', date: '2026-09-15', text: 'These, Argument, Fazit. Gegenargument nicht vergessen.' }],
),
'full',
);
const diff = await store.diff(before, after);
assert.ok(diff.changed.some((n) => n.kind === 'note'), 'an edited note is reported as changed');
});
it('keeps notes through a per-course crawl, which never looks at them', async () => {
// Notes belong to the account, not to a course, so a per-course refresh
// must carry them forward rather than appear to delete them.
const before = await store.latestCrawlId();
const after = await store.saveSnapshot(snapshot([{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }]), 'c1');
const diff = await store.diff(before!, after);
assert.ok(!diff.removed.some((n) => n.kind === 'note'), 'a per-course crawl must not delete the notes');
});
it('carries other courses forward on a per-course crawl', async () => {
await store.saveSnapshot(
snapshot([

160
test/untis-history.test.ts Normal file
View File

@@ -0,0 +1,160 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { chunkRange, collectLessonLog, hasContent, lessonLogText } from '../src/core/untis-history.ts';
import type { UntisClient, UntisLesson, UntisTimetable, UntisTopic } from '../src/core/untis.ts';
/**
* The class register, read backwards. The client is a stand-in: what is under
* test is which periods are asked about and how the two halves are merged, not
* the JSON-RPC layer, which test/untis.test.ts already covers.
*/
function lesson(overrides: Partial<UntisLesson> & { periodId: number; lessonId: number; date: string }): UntisLesson {
return {
start: '08:00',
end: '08:45',
statuses: ['REGULAR'],
cancelled: false,
changed: false,
subjects: [{ name: 'DE', longName: 'Deutsch' }],
teachers: [{ name: 'MEI', longName: 'Meier' }],
rooms: [],
classes: [],
replaced: { subjects: [], teachers: [], rooms: [] },
notes: {},
homework: [],
online: false,
...overrides,
};
}
function client(lessons: UntisLesson[], topics: Record<number, UntisTopic[]>, seen?: { periods: number[] }): UntisClient {
return {
async timetable(from: string, to: string): Promise<UntisTimetable> {
const days = [...new Set(lessons.map((entry) => entry.date))].filter((date) => date >= from && date <= to);
return { from, to, days: days.map((date) => ({ date, lessons: lessons.filter((l) => l.date === date), holidays: [] })) };
},
async lessonTopics(periodId: number): Promise<UntisTopic[]> {
seen?.periods.push(periodId);
const found = topics[periodId];
if (!found) throw new Error(`period ${periodId} not found`);
return found;
},
} as unknown as UntisClient;
}
describe('chunkRange', () => {
it('is one window for a short range', () => {
assert.deepEqual(chunkRange('2026-09-01', '2026-09-30'), [['2026-09-01', '2026-09-30']]);
});
it('splits a school year into windows the timetable call accepts', () => {
const chunks = chunkRange('2026-01-01', '2026-12-31');
assert.ok(chunks.length > 1);
assert.equal(chunks[0]![0], '2026-01-01');
assert.equal(chunks.at(-1)![1], '2026-12-31');
// No gaps and no overlaps: every day belongs to exactly one window.
for (let i = 1; i < chunks.length; i++) {
const previousEnd = new Date(`${chunks[i - 1]![1]}T12:00:00Z`).getTime();
const start = new Date(`${chunks[i]![0]}T12:00:00Z`).getTime();
assert.equal(start - previousEnd, 86_400_000);
}
});
});
describe('collectLessonLog', () => {
it('asks each series once, about its latest period', async () => {
// getLessonTopic2017 answers with the lessons *before* the period given,
// so the newest period of a series reaches all of its history and one
// call per series covers a term.
const seen = { periods: [] as number[] };
const lessons = [
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01' }),
lesson({ periodId: 2, lessonId: 100, date: '2026-09-08' }),
lesson({ periodId: 3, lessonId: 200, date: '2026-09-09' }),
];
const topics = {
2: [{ text: 'Erörterung', periodId: 1, date: '2026-09-01', start: '08:00', end: '08:45' }],
3: [{ text: 'Netze', periodId: 3, date: '2026-09-09', start: '08:00', end: '08:45' }],
};
await collectLessonLog(client(lessons, topics, seen), { from: '2026-09-01', to: '2026-09-30' });
assert.deepEqual(seen.periods.sort(), [2, 3]);
});
it('merges a topic onto the period it belongs to', async () => {
const lessons = [lesson({ periodId: 1, lessonId: 100, date: '2026-09-01' }), lesson({ periodId: 2, lessonId: 100, date: '2026-09-08' })];
const topics = { 2: [{ text: 'Erörterung', periodId: 1, date: '2026-09-01', start: '08:00', end: '08:45' }] };
const log = await collectLessonLog(client(lessons, topics), { from: '2026-09-01', to: '2026-09-30' });
assert.deepEqual(log.entries.map((entry) => [entry.periodId, entry.topic]), [[1, 'Erörterung']]);
});
it('keeps a lesson that has only a teacher note, and drops the empty ones', async () => {
const lessons = [
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', notes: { info: 'LK am 20.09.' } }),
lesson({ periodId: 2, lessonId: 100, date: '2026-09-08' }),
];
const log = await collectLessonLog(client(lessons, { 2: [] }), { from: '2026-09-01', to: '2026-09-30' });
assert.deepEqual(log.entries.map((entry) => entry.periodId), [1]);
assert.equal(log.periodsSeen, 2);
});
it('skips cancelled periods, which taught nothing', async () => {
const lessons = [lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', cancelled: true, notes: { info: 'Entfall' } })];
const log = await collectLessonLog(client(lessons, {}), { from: '2026-09-01', to: '2026-09-30' });
assert.equal(log.periodsSeen, 0);
assert.deepEqual(log.entries, []);
});
it('matches a subject on either its code or its long name', async () => {
const lessons = [
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', notes: { info: 'x' } }),
lesson({ periodId: 2, lessonId: 200, date: '2026-09-01', subjects: [{ name: 'LF07', longName: 'Lernfeld 7' }], notes: { info: 'y' } }),
];
const byCode = await collectLessonLog(client(lessons, {}), { from: '2026-09-01', to: '2026-09-30', subject: 'lf07' });
assert.deepEqual(byCode.entries.map((entry) => entry.periodId), [2]);
const byName = await collectLessonLog(client(lessons, {}), { from: '2026-09-01', to: '2026-09-30', subject: 'deutsch' });
assert.deepEqual(byName.entries.map((entry) => entry.periodId), [1]);
});
it('records a refused series rather than losing the whole term', async () => {
const lessons = [
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', notes: { info: 'bleibt' } }),
lesson({ periodId: 2, lessonId: 200, date: '2026-09-02', notes: { info: 'auch' } }),
];
// Period 2's series throws; period 1's does not.
const log = await collectLessonLog(client(lessons, { 1: [] }), { from: '2026-09-01', to: '2026-09-30' });
assert.equal(log.failures.length, 1);
assert.equal(log.entries.length, 2);
});
it('is newest first', async () => {
const lessons = [
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', notes: { info: 'a' } }),
lesson({ periodId: 2, lessonId: 100, date: '2026-09-08', notes: { info: 'b' } }),
];
const log = await collectLessonLog(client(lessons, { 2: [] }), { from: '2026-09-01', to: '2026-09-30' });
assert.deepEqual(log.entries.map((entry) => entry.date), ['2026-09-08', '2026-09-01']);
});
});
describe('lessonLogText', () => {
it('carries the topic, the announcement and the homework into one body', () => {
const body = lessonLogText({
periodId: 1, lessonId: 100, date: '2026-09-01', start: '08:00', end: '08:45',
teachers: ['Meier'], topic: 'Erörterung', notes: { info: 'LK am 20.09.' },
homework: [{ text: 'S. 42', due: '2026-09-08' }],
});
assert.match(body, /Erörterung/);
assert.match(body, /LK am 20\.09\./);
assert.match(body, /S\. 42/);
});
});
describe('hasContent', () => {
it('is false for a lesson that recorded nothing', () => {
assert.equal(
hasContent({ periodId: 1, lessonId: 1, date: '2026-09-01', start: '08:00', end: '08:45', teachers: [], notes: {}, homework: [] }),
false,
);
});
});