diff --git a/.env.example b/.env.example index a6fd66c..d14516b 100644 --- a/.env.example +++ b/.env.example @@ -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) # --------------------------------------------------------------------------- diff --git a/CLAUDE.md b/CLAUDE.md index 8621ba2..24a52ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 2cb9cd7..ed40df4 100644 --- a/README.md +++ b/README.md @@ -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/`, `schulcloud://rooms/`) 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 schulcloud fs tree /courses # browse the file manager ("Dateien") schulcloud fs get "/courses//" +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 diff --git a/docker-compose.yml b/docker-compose.yml index bb0c3f7..b5554db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/API.md b/docs/API.md index 9d5f5b8..849662e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 diff --git a/docs/CLI.md b/docs/CLI.md index ba2859a..14a2986 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -39,6 +39,7 @@ schulcloud ls [--course ] [--long] schulcloud get [--out ] schulcloud sync [--dry-run] [--full] [--prune] [--dir ] [--jobs ] schulcloud refresh [--course ] [--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//…` 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 ] [--since ] [--until ] [--long] +schulcloud note show +schulcloud note add --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 diff --git a/docs/NOTES.md b/docs/NOTES.md new file mode 100644 index 0000000..b48153f --- /dev/null +++ b/docs/NOTES.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 72f3148..dd6dbb4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/local-instance/scripts/mcp-env.sh b/local-instance/scripts/mcp-env.sh index 90097b4..7a77597 100755 --- a/local-instance/scripts/mcp-env.sh +++ b/local-instance/scripts/mcp-env.sh @@ -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 diff --git a/scripts/export-apple-notes.js b/scripts/export-apple-notes.js new file mode 100755 index 0000000..8f29840 --- /dev/null +++ b/scripts/export-apple-notes.js @@ -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); +} diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index c998452..bc535af 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -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); diff --git a/src/bin/cli.ts b/src/bin/cli.ts index ae09135..120a797 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -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; diff --git a/src/cli/apple-notes.ts b/src/cli/apple-notes.ts new file mode 100644 index 0000000..2c1263f --- /dev/null +++ b/src/cli/apple-notes.ts @@ -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; +} diff --git a/src/cli/client.ts b/src/cli/client.ts index 33a470a..77197aa 100644 --- a/src/cli/client.ts +++ b/src/cli/client.ts @@ -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.'; diff --git a/src/cli/notes.ts b/src/cli/notes.ts new file mode 100644 index 0000000..4756186 --- /dev/null +++ b/src/cli/notes.ts @@ -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}`; +} diff --git a/src/config.ts b/src/config.ts index b60f387..c414bb0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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(), }; } diff --git a/src/core/crawl.ts b/src/core/crawl.ts index b982443..4d8d274 100644 --- a/src/core/crawl.ts +++ b/src/core/crawl.ts @@ -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, }; } diff --git a/src/core/match.ts b/src/core/match.ts index 64b849b..eead4fb 100644 --- a/src/core/match.ts +++ b/src/core/match.ts @@ -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({ diff --git a/src/core/notes.ts b/src/core/notes.ts new file mode 100644 index 0000000..6c10c1b --- /dev/null +++ b/src/core/notes.ts @@ -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); +} diff --git a/src/core/untis-history.ts b/src/core/untis-history.ts new file mode 100644 index 0000000..d6d4700 --- /dev/null +++ b/src/core/untis-history.ts @@ -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; +} diff --git a/src/http/api.ts b/src/http/api.ts index 11cf2a5..b2bfcf7 100644 --- a/src/http/api.ts +++ b/src/http/api.ts @@ -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; diff --git a/src/indexer/indexer.ts b/src/indexer/indexer.ts index 0a28856..0c1c32e 100644 --- a/src/indexer/indexer.ts +++ b/src/indexer/indexer.ts @@ -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. * diff --git a/src/mcp/prompts.ts b/src/mcp/prompts.ts index b0e5730..73039d9 100644 --- a/src/mcp/prompts.ts +++ b/src/mcp/prompts.ts @@ -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.', diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f3def33..269431e 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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); diff --git a/src/mcp/tools/index-tools.ts b/src/mcp/tools/index-tools.ts index 15f80c5..2c7ffa2 100644 --- a/src/mcp/tools/index-tools.ts +++ b/src/mcp/tools/index-tools.ts @@ -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.'), diff --git a/src/mcp/tools/notes.ts b/src/mcp/tools/notes.ts new file mode 100644 index 0000000..e7c03c6 --- /dev/null +++ b/src/mcp/tools/notes.ts @@ -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.', + ]); +} diff --git a/src/mcp/tools/search.ts b/src/mcp/tools/search.ts index 993fbc4..677378d 100644 --- a/src/mcp/tools/search.ts +++ b/src/mcp/tools/search.ts @@ -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 { diff --git a/src/mcp/tools/untis.ts b/src/mcp/tools/untis.ts index 178d768..63577e8 100644 --- a/src/mcp/tools/untis.ts +++ b/src/mcp/tools/untis.ts @@ -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 diff --git a/src/services.ts b/src/services.ts index 2d9b86e..6aa4392 100644 --- a/src/services.ts +++ b/src/services.ts @@ -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( diff --git a/src/store/store.ts b/src/store/store.ts index 86deea4..cdc303f 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -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)); } diff --git a/test/apple-notes.test.ts b/test/apple-notes.test.ts new file mode 100644 index 0000000..59f1076 --- /dev/null +++ b/test/apple-notes.test.ts @@ -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örterung & 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/); + }); +}); diff --git a/test/notes.test.ts b/test/notes.test.ts new file mode 100644 index 0000000..0a32cdc --- /dev/null +++ b/test/notes.test.ts @@ -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']); + }); +}); diff --git a/test/store.test.ts b/test/store.test.ts index da92870..2bad958 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -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([ diff --git a/test/untis-history.test.ts b/test/untis-history.test.ts new file mode 100644 index 0000000..c7bee59 --- /dev/null +++ b/test/untis-history.test.ts @@ -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, + ); + }); +});