diff --git a/.env.example b/.env.example index a6fd66c..2104115 100644 --- a/.env.example +++ b/.env.example @@ -84,6 +84,36 @@ 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 + +# Password for the web app at /app — the notes editor and the settings page +# where the Schulcloud token is replaced. Unset = the app is not served at all. +# +# Unlike every other credential here this one is typed by a person on a phone, +# so it is a passphrase rather than a random token: at least 12 characters, and +# three or four words is the right shape. It is hashed with scrypt at startup +# and the plain value is never stored, compared or logged. Changing it logs out +# every session, because the session signing key is derived from it. +# +# The app is on the internet like the rest of the endpoint. Failed logins are +# rate-limited per address, but the password is the thing protecting the notes +# and the Schulcloud token — pick a long one. +# WEB_PASSWORD= + # --------------------------------------------------------------------------- # WebUntis (optional — the timetable, which Schulcloud does not hold) # --------------------------------------------------------------------------- @@ -106,6 +136,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..050035e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,11 +8,17 @@ 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. +- `src/bin/http.ts` — Streamable HTTP + `/api` + `/app`, the deployed form, behind Caddy on a Pi. - `src/bin/stdio.ts` — stdio, for local Claude Code / Desktop use. - `src/bin/cli.ts` — the `schulcloud` CLI, which talks to the HTTP server, never to Schulcloud. @@ -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: **106/107 against the local instance** on 2026-09-19 (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,25 @@ 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. + - **`day-note.ts`** — a school day as a note: the timetable turned into one + `##` heading per lesson. `lessonHeading` and `notes.ts`'s + `subjectFromHeading` are a **loop** — the app writes the heading and the + indexer reads the subject back out of it, so a change to either without the + other files a day's notes under nothing. `test/day-note.test.ts` holds them + to it. + - **`notes.ts`** — the user's own lesson notes as a directory of Markdown + files with a small frontmatter dialect. A note with `##` headings is a + school day and is indexed **per lesson**, not whole: indexed whole, every + hit would read "my note, Monday" and "what did we do in Deutsch" would match + a note whose other five lessons were something else. 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. @@ -106,6 +139,20 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync open `/api`. Besides those: the optional `//mcp` for clients without headers (`MCP_PATH_SECRET`), and `/token`, a page that PUTs a fresh token to `/api/token`. docs/AUTH.md and docs/DEPLOYMENT.md say why each exists. +- **`http/app-page.ts` + `http/app/`** — `/app`, the one surface meant for a + person rather than a program: the day's notes and a settings page for the + Schulcloud token. Served only when `WEB_PASSWORD` is set. Its assets are + **files** under `src/http/app/`, copied to `dist/` by `scripts/copy-assets.mjs` + and read relative to `import.meta.dirname` — real HTML, CSS and JS that an + editor and a linter understand, which is also what the CSP requires, since it + forbids inline script. +- **`http/web-auth.ts`** — the app's login, which is a different kind of + credential from everything else here: a password a person types, not a token a + program was configured with. scrypt at startup, a signed `HttpOnly` / + `SameSite=Strict` session cookie, per-address rate limiting. The session key + is **derived from the password**, so changing it logs every session out and + there is no second secret to store. The cookie opens `/api` — a session *is* + the user — and never `/mcp`. See Invariants. - **`store/`** — crawl generations, identity diffs, `german` + `pg_trgm` FTS. `Store.open` returns `undefined` when Postgres is down; callers degrade. - **`indexer/`** — crawl → persist → mirror bytes → extract text → index. @@ -121,6 +168,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 +201,27 @@ 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 app's session opens `/api`, never `/mcp`, and the app is not served +without a password.** `WEB_PASSWORD` is the only credential here a human types, +so it is the only one that can be guessed: the rate limiter in `web-auth.ts` is +not decoration, and the scrypt cost that makes guessing expensive is itself a +denial-of-service vector without it. The password is hashed at startup and never +stored, compared or logged in the clear — it is a secret by the rule below, and +so is the session cookie. Unset means the app does not exist, the same rule the +`untis_*` and note tools follow: a login screen no password can open is worse +than no page, because it looks like a way in. + +**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 @@ -166,7 +243,8 @@ the property that makes that acceptable. Do not add a write tool without the user explicitly asking for one and understanding this. **Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the -account; `MCP_AUTH_TOKEN` and `MCP_CONNECTOR_TOKEN` guard the endpoint; +account; `MCP_AUTH_TOKEN`, `MCP_CONNECTOR_TOKEN` and `WEB_PASSWORD` guard the +endpoint; `UNTIS_SECRET` authenticates as the user in WebUntis and outlives every other credential here, since it does not expire. None belongs in logs, error messages, or tool output. `.env` is git-ignored — keep it that way. @@ -354,6 +432,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 +467,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 +495,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`, `UNTIS_HISTORY_DAYS` and `WEB_PASSWORD` (the app at `/app`); +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/Dockerfile b/Dockerfile index 3114666..75d4dae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,14 +30,16 @@ COPY --from=deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY package.json ./ -# The mirror and the state directory (a replaced session token) are the only -# writable paths. Creating them in the image with the right owner matters: Docker -# initialises a new named volume from the image directory, including its -# ownership, so without this the volume lands root-owned and the unprivileged -# user gets EACCES on every write — with the failure recorded rather than -# crashing, which makes it easy to miss. The state directory holds a credential, -# so only its owner may enter it. -RUN mkdir -p /data/mirror /data/state && chown -R node:node /data && chmod 700 /data/state +# The mirror, the state directory (a replaced session token) and the notes are +# the only writable paths. Creating them in the image with the right owner +# matters: Docker initialises a new named volume from the image directory, +# including its ownership, so without this the volume lands root-owned and the +# unprivileged user gets EACCES on every write — with the failure recorded +# rather than crashing, which makes it easy to miss. The state directory holds a +# credential and the notes are personal, so only their owner may enter either. +RUN mkdir -p /data/mirror /data/state /data/notes \ + && chown -R node:node /data \ + && chmod 700 /data/state /data/notes # node:alpine ships an unprivileged `node` user. USER node diff --git a/README.md b/README.md index 2cb9cd7..9cd9661 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,27 @@ 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). + +## The notes app + +A small web app at `/app`, for writing those notes: a login, the day's notes, +and a settings page for the Schulcloud token. Set `WEB_PASSWORD` to serve it. + +One note per school day, one `##` heading per lesson — and **the headings come +from WebUntis**, so opening a day gives you it already laid out with times, +teachers, rooms, cancellations dropped and substitutions marked. Each heading is +indexed as its own lesson, so a search answers "my own note, Deutsch, +18.09.2026" rather than "Friday". + +It saves as you type, keeps a local copy of every keystroke for when the signal +goes, and refuses a save that would overwrite a version it never saw. On a phone +it adds to the home screen and opens standalone. + **Three ready-made prompts**, in German because the school is: | | | @@ -87,6 +114,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 ``` @@ -107,7 +136,9 @@ npm run probe # verifies the token and API against the live instan To try it on your own machine — Docker stack, Claude Code, and the CLI — follow [docs/LOCAL.md](docs/LOCAL.md). To put it on a Pi behind Caddy and a VPS, and connect claude.ai, follow [docs/PI.md](docs/PI.md); -[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) explains the pieces. +[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) explains the pieces. To add the notes +app to a server that is already running, follow +[docs/DEPLOY-NOTES.md](docs/DEPLOY-NOTES.md). Getting `TSC_JWT_COOKIE` takes four clicks in DevTools and then lasts 30 days — provided you close the Schulportal window afterwards. See @@ -186,14 +217,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 + http/ express app, bearer auth, /api for the CLI, /app for people 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/deploy/Caddyfile.snippet b/deploy/Caddyfile.snippet index 6c3d1c3..2176ab0 100644 --- a/deploy/Caddyfile.snippet +++ b/deploy/Caddyfile.snippet @@ -14,7 +14,12 @@ mcp.example.org { # `schulcloud-mcp` is the Compose service name; Docker's embedded DNS # resolves it on the shared network. No host port is published. One proxy - # serves both surfaces: /mcp for Claude and /api for the CLI. + # serves all three surfaces: /mcp for Claude, /api for the CLI, and /app for + # the notes app in a browser. + # + # Caddy sets X-Forwarded-Proto on its own, and the app needs it: the hop to + # the container is plain HTTP, so that header is the only evidence the + # session cookie may be marked Secure. Do not strip it. reverse_proxy schulcloud-mcp:8080 { # MCP's Streamable HTTP transport keeps a server-sent-events channel # open for server-initiated messages. Without flush_interval -1 Caddy 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/AUTH.md b/docs/AUTH.md index c0b11ce..a92b674 100644 --- a/docs/AUTH.md +++ b/docs/AUTH.md @@ -251,15 +251,50 @@ access log entry is written. Rotating it means a new value, a recreated container, and re-adding the connector. Prefer the connector token wherever a header can be sent: a URL is copied into more places than a header is. +### The app password, for a person + +`WEB_PASSWORD` is unlike every other credential here: it is typed by a human, on +a phone, in a lesson. That single fact drives its whole design. + +- **It is a passphrase, not a token.** `config.ts` insists on 12 characters and + nothing else; demanding punctuation would buy little next to length, and the + failure mode of a fussy rule is a shorter password, not a better one. +- **It is never stored in the clear.** scrypt (N=16384) at startup; a login + hashes the attempt and compares in constant time. The error states the rule + and never echoes the value. +- **Logins are rate-limited per address**, eight failures in fifteen minutes. + Not optional: a password is guessable in a way a 32-byte token is not, and the + scrypt cost that makes guessing expensive is itself a denial-of-service vector + without a limiter in front of it. +- **The session is a signed cookie**, `HttpOnly` and `SameSite=Strict` — the + latter standing in for CSRF tokens, since nothing links into the app from + anywhere else. 30 days, because the alternative is a login screen at the start + of a lesson. +- **The signing key is derived from the password**, so changing it invalidates + every session that exists. No second secret, nothing to store, and the + behaviour anyone changing a password already expects. +- **The cookie opens `/api` and not `/mcp`.** A session *is* the user, and the + app is built on `/api` — but nothing in a browser speaks MCP, and a surface + that is not needed is not offered. + +Unset, the app is not served at all. A login screen that no password can open is +worse than no page, because it looks like a way in. + ## Blast radius Every path in this server is a `GET`, including the `api_get` escape hatch, which rejects anything not starting with `/api/` and anything carrying a scheme or host. Someone who obtained both the endpoint URL and `MCP_AUTH_TOKEN` — or -the connector token, or the secret MCP path — could read this account's -Schulcloud data; they could not +the connector token, the secret MCP path, or the app password — could read this +account's Schulcloud data; they could not post, submit, delete, or otherwise act as the user. With `MCP_AUTH_TOKEN` they could also call `PUT /api/token`, but it accepts only a live token for the same account, so the most it can do is hand the server a session the owner already has. Keep it that way — adding a single write tool would change that property entirely. + +The app password and `MCP_AUTH_TOKEN` additionally reach the notes: they can +read, write and overwrite files under `NOTES_DIR`, and nothing outside it — +`safeComponent` and `resolveWithin` are what make that a property rather than a +hope. That is the only write anywhere in this server, and it touches the user's +own files, never Schulcloud. `NOTES_READONLY=1` removes even that. 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/DEPLOY-NOTES.md b/docs/DEPLOY-NOTES.md new file mode 100644 index 0000000..1a08a42 --- /dev/null +++ b/docs/DEPLOY-NOTES.md @@ -0,0 +1,262 @@ +# Rolling out the notes app + +A runbook for putting the notes feature and the `/app` web app onto a +deployment that is already running. [PI.md](PI.md) is the first-time setup; +this is the upgrade, and it assumes the server is live and healthy. + +Read [NOTES.md](NOTES.md) first if you have not: **step 2 is a decision you +should not make twice**, because changing it later means moving files by hand. + +## What arrives + +| | | +|---|---| +| **Your own lesson notes** | A directory of Markdown files the server reads, indexes and searches beside Schulcloud and WebUntis. Three tools: `list_notes`, `get_note`, `add_note`. | +| **The app at `/app`** | A login, a day-at-a-time notes editor, and a settings page that replaces the Schulcloud token. Only served when `WEB_PASSWORD` is set. | +| **The WebUntis class register** | `untis_lesson_topics` now takes a subject as well as a period id, and `UNTIS_HISTORY_DAYS` of "what was actually taught" goes into the search index. | + +Nothing here changes Schulcloud or WebUntis: both stay read-only. The notes +directory is the only thing this server writes to, and it is yours. + +## Before you start + +- **Ten minutes**, plus however long a full crawl takes (the first one with a + class register adds a few dozen WebUntis requests; it runs in the background). +- **No Caddy change.** `/app` is served by the same container on the same port, + and the snippet already proxies everything. One line was added to + `deploy/Caddyfile.snippet` as a comment about `X-Forwarded-Proto`; if your + site block predates it, check that nothing strips that header — the session + cookie's `Secure` flag depends on it. +- **No database migration.** Notes and class-register entries are new node kinds + in a column that is plain `TEXT`. +- **No compose change on the Pi.** `deploy/docker-compose.pi.yml` overrides only + the image, the database URL and the network, so the `notes` volume and + `NOTES_DIR=/data/notes` come from `docker-compose.yml` unchanged. + +## 1. Publish the image + +On a development machine, from a clean checkout of `main`: + +```bash +npm run publish-image +``` + +It builds arm64 and amd64 and pushes `latest` plus the commit id. If you pin +`SCHULCLOUD_MCP_TAG`, note the short commit it prints — you need it in step 3. + +## 2. Decide where the notes live + +**Do this before anything writes a note.** Both options work; moving between +them afterwards means moving files. + +**A — the volume (default, nothing to do).** `docker-compose.yml` already +declares a `notes` volume at `/data/notes`. The app and the CLI write to it, +`add_note` writes to it, and that is the whole story. Choose this if you will +write notes in the app and nowhere else. + +**B — a directory you sync.** Choose this to *also* write notes from a phone or +a laptop in an editor — Obsidian, iA Writer, a git repo. Edit +`docker-compose.yml` on the Pi: + +```yaml +# under schulcloud-mcp: + volumes: + - mirror:/data/mirror + - state:/data/state + - /home/pi/Notizen:/data/notes # was: notes:/data/notes +``` + +```bash +mkdir -p /home/pi/Notizen +``` + +The container runs unprivileged and `read_only`, so that directory must be +writable by the container's user — if the logs show `EACCES` for `/data/notes`, +see Troubleshooting. Then point Syncthing, Nextcloud or `git` at it. The server +does not care which; new files are picked up by the next full crawl. + +## 3. Configure + +On the Pi, in `/opt/schulcloud-mcp`: + +```bash +cd /opt/schulcloud-mcp +git pull +``` + +Add the app password to `.env`. It is the only credential here a person types, +so it is a passphrase rather than a token — **at least 12 characters, and three +or four words is the right shape**: + +```bash +cat >> .env <<'EOF' + +# --- the notes app --- +WEB_PASSWORD=change-this-to-three-or-four-words +EOF +``` + +Optional, on the same pass: + +| Setting | | +|---|---| +| `UNTIS_HISTORY_DAYS` | How far back to index the class register. Default 180; `0` turns it off. Only does anything with `UNTIS_*` configured. | +| `NOTES_READONLY=1` | Refuse every write. `list_notes` and `get_note` still work, `add_note` and the app's save do not. Right when the notes are synced in and should have exactly one writer. | +| `SCHULCLOUD_MCP_TAG` | If you pin images, set it to the commit from step 1. | + +**Put `WEB_PASSWORD` in your password manager now.** The server never prints it, +and changing it logs out every session. + +## 4. Start it + +```bash +docker compose pull +docker compose up -d +docker compose logs --tail 20 schulcloud-mcp +``` + +The startup line names what is on. With everything configured it ends +`… keepalive every 30min, index every 6h`; the app and the notes are not named +there, so verify them in the next step rather than reading the log for them. + +## 5. Verify + +From anywhere: + +```bash +curl -s https://mcp.example.org/healthz +# {"status":"ok","sessions":0,"index":"on"} + +curl -s -o /dev/null -w '%{http_code}\n' https://mcp.example.org/app/ +# 200 ← the app is served; 404 means WEB_PASSWORD is not set +``` + +Then open `https://mcp.example.org/app/` in a browser and log in. + +- **Notizen** should show today, and — if WebUntis is configured — today's + lessons as headings, with times, teacher and room. "Kein Unterricht an diesem + Tag" on a company-phase week or a weekend is correct, not a fault. +- Type a line and wait two seconds. The status line should read + **Gespeichert HH:MM**. +- **Einstellungen** should show the Schulcloud token's remaining days and the + index's state. + +On a phone, add it to the home screen — it has a manifest and opens standalone. + +Check the file landed where you meant it to: + +```bash +docker compose exec schulcloud-mcp ls -R /data/notes +# 2026/2026-09-19.md +``` + +## 6. Bring the old notes in + +If you have notes in Apple Notes, migrate them now — see +[NOTES.md](NOTES.md#migrating-out-of-apple-notes). Briefly, on the Mac: + +```bash +osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson +schulcloud note import notes.ndjson --dry-run # look first +schulcloud note import notes.ndjson +``` + +Import **once**. Re-running creates second copies, because the importer cannot +tell an edited note from a new one with the same title. + +## 7. Index them + +Notes and the class register are only read by a **full** crawl. One runs on the +timer (`CRAWL_INTERVAL_MS`, six hours by default), or force one now: + +```bash +schulcloud refresh --force +``` + +Expect it to take minutes; the CLI polls and prints progress. When it finishes: + +```bash +schulcloud status +``` + +Then ask Claude something only the new sources can answer — *"what did I write +down in Deutsch last week?"* or *"what did we actually cover in LF07 this +term?"* — and check the answer names your note or the class register as its +source. + +## Backups, which now matter more + +**The notes are the only irreplaceable thing this server holds.** Everything +else it stores is a copy of something upstream; a note you took in a lesson is +not, and nothing can rebuild it. + +| What | Needed? | +|---|---| +| `.env` | **Yes** — every secret, `WEB_PASSWORD` included. Encrypted only. | +| **`schulcloud-mcp_notes`** (option A) | **Yes. Nothing can regenerate these.** | +| **Your synced directory** (option B) | **Yes**, unless the sync tool already keeps versioned copies elsewhere — and check that it does, rather than assuming. | +| Postgres | Optional: a crawl rebuilds it. | +| `schulcloud-mcp_mirror` | No — re-downloaded by the next crawl. | +| `schulcloud-mcp_state` | No — a replaced token, expiring within 30 days anyway. | + +With the volume (option A): + +```bash +docker run --rm -v schulcloud-mcp_notes:/notes:ro -v "$PWD":/out alpine \ + tar czf /out/notizen-$(date +%F).tar.gz -C /notes . +``` + +They are small — a school year of notes is a few megabytes — so back them up +often and keep the old copies. + +## Rolling back + +The feature adds no migration and no incompatible state, so going back is the +ordinary downgrade: + +```bash +# in .env +SCHULCLOUD_MCP_TAG=<previous commit> +``` + +```bash +docker compose pull && docker compose up -d +``` + +The old image ignores `WEB_PASSWORD` and `NOTES_DIR` and serves no `/app`. **The +notes volume is untouched** — the files stay, and the newer image picks them up +again unchanged. The only thing lost while rolled back is the ability to read or +write them. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `/app/` answers 404 | `WEB_PASSWORD` not set, or the container not recreated since it was | `grep ^WEB_PASSWORD= .env`, then `docker compose up -d --force-recreate schulcloud-mcp` | +| The server refuses to start, log names `WEB_PASSWORD` | Shorter than 12 characters | Use a longer passphrase | +| Login says the password is wrong, and it is not | `.env` is read at container creation | `docker compose up -d --force-recreate schulcloud-mcp` | +| Logged out constantly, or the login "does nothing" | The session cookie is marked `Secure` and the connection is not HTTPS, or `X-Forwarded-Proto` is stripped | Reach it over HTTPS; check the Caddy site does not strip that header | +| `Zu viele Fehlversuche` | The per-address rate limiter, eight failures in fifteen minutes | Wait it out; it is doing its job | +| The editor says the server keeps no notes | `NOTES_DIR` unset on the server | It is set by `docker-compose.yml`; check `COMPOSE_FILE` in `.env` still lists it first | +| `Der Server nimmt keine Änderungen an` | `NOTES_READONLY` is on | Remove it and recreate the container | +| Saves refused as a conflict, repeatedly | The note is being changed elsewhere — a sync tool, another device | Choose a version in the banner; if a sync tool keeps rewriting the file, it is fighting the app | +| `EACCES` for `/data/notes` in the logs | A bind-mounted directory the container's user cannot write | `sudo chown -R 1000:1000 /home/pi/Notizen` (match the image's user), then recreate | +| Notes exist but `search` cannot find them | Only a full crawl reads them | `schulcloud refresh --force` | +| `search` finds a day note but names no subject | The lesson headings were rewritten past recognition | Keep `## 1. Deutsch …`; the leading number and the subject are what the index reads | +| `untis_lesson_topics` with a subject finds nothing | The subject code differs from what you typed | Check it against `untis_timetable`; the register uses the school's own codes | + +## Security notes for this rollout + +- `WEB_PASSWORD` is the first credential here that a human types, so the first + that can be guessed. It is hashed with scrypt at startup and never stored, + compared or logged in the clear, and failed logins are rate-limited per + address — but **length is what actually protects it**. +- The session cookie opens `/api`, which can read your coursework and replace + the Schulcloud token. It does not open `/mcp`. Treat a login on a shared + device as you would treat the token. +- Changing `WEB_PASSWORD` invalidates every session, because the signing key is + derived from it. That is the revocation mechanism: change it, recreate the + container, log in again. +- The write surface is bounded to `NOTES_DIR` by `safeComponent` and + `resolveWithin` — the same two functions that stop a hostile Schulcloud + filename escaping the file mirror. Schulcloud and WebUntis remain read-only. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 5bb7433..7186f96 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -216,6 +216,35 @@ for a deployment: time-based code; a drifting clock is refused with "invalid client time", which the tools report in those words. +## The notes app + +Rolling this onto a server that is already running has its own runbook: +[DEPLOY-NOTES.md](DEPLOY-NOTES.md). + +Set `WEB_PASSWORD` in `.env` and the server offers `/app`: the notes editor and +a settings page. Unset, it is not served at all, and nothing else changes. + +``` +https://mcp.example.org/app/ +``` + +Three things worth knowing for a deployment: + +- **Recreate the container after changing the password** (`docker compose up -d + --force-recreate schulcloud-mcp`) — `env_file` is read at creation. Changing + it also logs out every session, by design: the session signing key is derived + from it. +- **Notes need somewhere to live.** `docker-compose.yml` sets + `NOTES_DIR=/data/notes` with a volume of its own. To write the notes from a + phone through a sync tool as well as through the app, bind-mount a real + directory there instead — see [NOTES.md](NOTES.md). +- **The app is a way to replace the Schulcloud token**, which is the next + section, and the more comfortable one when the expiry catches you away from a + terminal. + +`/token` still exists and still works. It is the fallback for a deployment with +no `WEB_PASSWORD`, and it is unchanged. + ## Replacing the Schulcloud token The token lasts 30 days at most and can only come from a browser login (see @@ -224,8 +253,9 @@ or an `.env` edit: 1. Log in to Schulcloud in a **private window** and copy the `jwt` cookie's value (DevTools → Application → Cookies). -2. Either run `schulcloud token set` and paste it, or open - `https://mcp.example.org/token` and paste it together with `MCP_AUTH_TOKEN`. +2. Either run `schulcloud token set` and paste it, open the app's + **Einstellungen** tab, or open `https://mcp.example.org/token` and paste it + together with `MCP_AUTH_TOKEN`. 3. **Close the private window.** The server checks the token with Schulcloud first — right account, not diff --git a/docs/NOTES.md b/docs/NOTES.md new file mode 100644 index 0000000..358844a --- /dev/null +++ b/docs/NOTES.md @@ -0,0 +1,262 @@ +# Your own lesson notes + +Schulcloud holds the material and WebUntis holds the schedule. Neither holds +what was actually said in the room — which teacher stressed what, the example +that finally made it click, the aside that turns up in the test. That is in +whatever you write down during the lesson, and until now it lived somewhere no +agent could read. + +This is the third source: a directory of Markdown files the server reads, +indexes and searches alongside everything else, and a small web app to write +them in. + +## One note per school day + +``` +NOTES_DIR/ + 2026/ + 2026-09-18.md ← Freitag, one heading per lesson + 2026-09-21.md + Deutsch/ + 2026-09-15 Erörterung.md ← a single-subject note, e.g. from the import +``` + +A day note looks like this, and the shape is load-bearing: + +```markdown +--- +title: Freitag, 18.09.2026 +date: 2026-09-18 +source: notes-page +--- + +## 1. Deutsch — 08:00–08:45 · MEI · R 204 + +Dreischritt: These, Argument mit Beleg, Fazit. + +### Aufbau + +- Gegenargument nicht vergessen — kam letztes Jahr in der Arbeit dran + +## 2. LF07 — 08:50–09:35 · Sb · R 108 + +| Präfix | Nutzbare Adressen | +|---|---| +| /24 | 254 | +``` + +**Each `##` is indexed as its own lesson.** A search for *Gegenargument* answers +"my own note, Deutsch, 18.09.2026", not "my own note, Friday"; `list_notes +subject=Deutsch` finds this day even though the note's frontmatter names no +subject; and `what_changed` reports the lesson that changed rather than the +whole day. Prose, lists, tables and `###` subheadings all live inside their +lesson. + +Nothing forces the shape. A note with no `##` headings — an imported Apple Note, +a page of revision — is indexed whole, as one piece of prose. + +## The app + +`/app` is a small web app: a login, the day's notes, and a settings page. It is +served only when `WEB_PASSWORD` is set. + +``` +https://mcp.example.org/app/ +``` + +On a phone it is worth adding to the home screen — it has a manifest and opens +standalone, which is the difference between "a page I have to find" and "the +thing I open in a free period". + +**Notizen** is one screen: the day, `‹ ›` to move between days, and the editor. + +- Opening a day with no note yet **fills in that day's lessons from WebUntis** — + numbered, with times, teacher and room, cancellations left out and + substitutions marked. That is the whole reason the app is day-shaped: the one + thing WebUntis knows and you should not have to retype. +- **Stunden ergänzen** appears when the timetable has a lesson your note does + not — a day you started writing before it ended, or a timetable that changed + after you started. It appends what is missing and never touches what you wrote. +- It **saves as you type** (a couple of seconds after you stop), when the app + goes to the background, and when the phone locks. +- Every keystroke also goes to the browser's local storage. If the connection + drops mid-lesson you keep writing, and the next time the app loads that day it + offers the version this device has. **A note taken in a lesson cannot be + retaken**, which is the reasoning behind all of this. +- If the note changed elsewhere since you opened it — the laptop, a sync tool, + `add_note` — the save is refused and you are asked which version wins. It + never silently overwrites. + +**Einstellungen** holds the Schulcloud token: how long it has left, and the box +to paste a fresh `jwt` cookie into when it expires (the same thing `schulcloud +token set` and the older `/token` page do). It also shows the index's state and +the notes directory, and has the logout button. + +### The login + +`WEB_PASSWORD` is the app's password — at least 12 characters, and a passphrase +of three or four words is the right shape. It is hashed with scrypt at startup; +the plain value is never stored, compared or logged. + +Logging in sets an `HttpOnly`, `SameSite=Strict` session cookie that lasts 30 +days, so a lesson never starts with a login screen. The cookie opens `/api` — +it *is* the user — but not `/mcp`, which no browser needs. Changing +`WEB_PASSWORD` invalidates every session, because the signing key is derived +from it. Failed logins are rate-limited per address; a password is guessable in +a way a 32-character token is not, and this endpoint is on the internet. + +If `WEB_PASSWORD` is unset the app is not served at all — the same rule the +`untis_*` and note tools follow. A login screen that no password can open is +worse than no page, because it looks like a way in. + +## The other four ways to write a note + +The app is not privileged; it writes the same files as everything else. + +```bash +# the command line, text piped in +pbpaste | schulcloud note add --title "Subnetting" --subject LF07 + +# an editor, or anything else that writes files +$EDITOR "$NOTES_DIR/2026/2026-09-18.md" + +# Claude, during or after the lesson +# "halt fest: Gegenargument nicht vergessen, kam letztes Jahr dran" +# → add_note, subject Deutsch, today's date + +# a folder you already sync — see below +``` + +`add_note` and `schulcloud note add` take `append`, which adds to the note +already written **for that subject and that day** rather than starting a second +one — so "note this down too" mid-lesson lands in the note that is already open, +whatever its title. + +## What may write, and where + +**The notes directory is the only thing this server writes to.** Not Schulcloud, +not WebUntis — those stay read-only, strictly, and that has not changed. + +Every path component goes through `safeComponent` and the result through +`resolveWithin`, the same two functions that stop a hostile Schulcloud filename +escaping the file mirror, so a note titled `../../.ssh/authorized_keys` becomes +a filename. `NOTES_READONLY=1` refuses writes entirely — right when the notes +are synced in from somewhere else and should have exactly one writer. The app +then still reads them, and says so rather than failing on save. + +## Migrating out of Apple Notes + +Notes.app has no export. Its database is a Core Data store whose bodies are +compressed protobuf and whose iCloud copy is encrypted, so scripting the app is +not the clumsy route to your notes — it is the only one. + +**On the Mac**, from a checkout of this repo: + +```bash +osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson +``` + +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 look at what it would do, and do it: + +```bash +schulcloud note import notes.ndjson --dry-run +schulcloud note import notes.ndjson # into the server +schulcloud note import notes.ndjson --out ~/Notizen # or to a local folder first +``` + +``` +would import: 2026-09-15 · Deutsch · Erörterung +Would import 84 of 91 note(s), skipped 5 with no text, 2 unreadable in Notes. +``` + +What it does with each note: + +- **The Notes folder becomes the subject** — its last segment, so `Schule/Deutsch` + is `Deutsch`. Notes' own default folders (`Notizen`, `Recently Deleted`) are + ignored rather than becoming a subject. `--subject LF07` overrides all of it. +- **The creation date becomes the note's date**, because that is the day of the + lesson. The modification date is whenever you last tidied it up, which is not + a school day at all. +- **The HTML becomes Markdown** — headings, lists, checklists, bold, italics and + links survive; anything else keeps its words and loses its tag. +- **Attachments do not come across.** A note that was a photo of the board + imports as a line saying an attachment was there. Better than importing empty: + you can see which notes still need the picture. +- **Locked notes cannot be read at all** and are listed by name at the end. + Unlock them in Notes and export again. + +Imported notes arrive as single-subject notes, not day notes, which is the +shape they were written in. Both kinds coexist. + +Re-running the import creates second copies rather than overwriting, since the +importer cannot tell an edited note from a new one with the same title. Import +once, then keep writing in the new place. + +## Reading them + +| Tool | Answers | +|---|---| +| `list_notes` | "what did I write down in Deutsch before the test" — by subject or date, lesson headings included | +| `get_note` | one note in full, by the path everything else prints | +| `search` | notes by their contents, per lesson, next to board text and the inside of PDFs | +| `what_changed` | lessons and notes that appeared or were edited since a date | + +The `pruefungsvorbereitung`, `zusammenfassung` and `tagesvorbereitung` prompts +consult them on their own, and are told to say when a note disagrees with the +uploaded material rather than quietly preferring one. + +## Deploying it + +Adding this to a server that is already running — the password, where the notes +live, backups and rollback — is [DEPLOY-NOTES.md](DEPLOY-NOTES.md). + +## Keeping them somewhere you already sync + +The notes are files, so any sync tool will do and the server does not need to +know which. Bind-mount the folder instead of using the volume: + +```yaml +# docker-compose.yml, under schulcloud-mcp: + volumes: + - /home/pi/Notizen:/data/notes +``` + +Then point Syncthing, Nextcloud, an Obsidian vault or `git` at +`/home/pi/Notizen`. New files are picked up by the next crawl; nothing has to be +told about them, and the app edits the same files. + +Frontmatter is a small YAML subset — scalars and lists, in either the inline +`[a, b]` or the indented `- item` form editors write — so an Obsidian vault +round-trips. Unknown keys are kept and ignored. + +What the server reads out of a note: + +| Field | Falls back to | +|---|---| +| `title` | the first `# heading`, else the filename without its date | +| `date` | a `YYYY-MM-DD` the **filename** starts with, else nothing | +| `subject` | the first folder name — except a year, which is a filing scheme | +| `tags` | none | +| `courseId` | none — set it to tie a note to a Schulcloud course in `search` | + +`date` also accepts `15.09.2026`, and `fach:` works as a German spelling of +`subject:`. + +**The file's modification time is never used as the date.** An import writes +every note today; dating a year of lessons "today" would make the whole store +useless for revision, which is most of what it is for. + +## What the crawl does with them + +A **full** crawl reads the directory and indexes every note — one entry per +lesson for a day note, one for the whole note otherwise, under the kind `note`. +A **per-course** refresh leaves them alone, and the store carries the previous +generation's rows forward, so a per-course crawl never looks like the notes were +deleted. + +Notes are diffed by content, not by modification time, so a sync tool that +rewrites a file byte-for-byte does not show up in `what_changed` as an edit. diff --git a/docs/PI.md b/docs/PI.md index 0040533..1d425b6 100644 --- a/docs/PI.md +++ b/docs/PI.md @@ -88,6 +88,17 @@ INDEX_PERSONAL_FILES=true EOF ``` +To write your own lesson notes — and to replace the Schulcloud token from a +phone rather than a terminal — set a password for the app at `/app`. Unlike the +tokens above it is typed by a person, so it is a passphrase: **at least 12 +characters, three or four words**. See [NOTES.md](NOTES.md). + +```bash +cat >> .env <<'EOF' +WEB_PASSWORD=change-this-to-three-or-four-words +EOF +``` + If your school publishes its timetable in **WebUntis**, add those four values too — the timetable, its cancellations and substitutions are not in Schulcloud at all. They are in WebUntis under Profil → Freigaben → Untis Mobile → QR-Code: @@ -112,13 +123,15 @@ What those lines do: | `MCP_CONNECTOR_TOKEN` | What claude.ai sends as a request header. It opens `/mcp` only, never `/api`, because claude.ai stores it. | | `INDEX_PERSONAL_FILES` | Also indexes your own files and handed-in work, including teachers' feedback. Optional. | | `UNTIS_*` | WebUntis, where the school keeps the timetable. All four or none; the key needs no password and does not expire. See [AUTH.md](AUTH.md). Optional. | +| `WEB_PASSWORD` | The app at `/app`: the notes editor and a settings page for the Schulcloud token. Unset means no app is served at all. Hashed at startup and never logged; changing it logs out every session. Optional. | | `SCHULCLOUD_MCP_TAG` | Which published image to run. Unset means `latest`; a commit id such as `bac9130` pins it, so updates happen only when you change it. Optional. | -**Copy `MCP_AUTH_TOKEN` and `MCP_CONNECTOR_TOKEN` into your password manager -now** — you need both again in step 9, and neither is ever printed by the server: +**Copy `MCP_AUTH_TOKEN`, `MCP_CONNECTOR_TOKEN` and `WEB_PASSWORD` into your +password manager now** — you need the first two again in step 9, and none of +them is ever printed by the server: ```bash -grep -E '^(MCP_AUTH_TOKEN|MCP_CONNECTOR_TOKEN)=' .env +grep -E '^(MCP_AUTH_TOKEN|MCP_CONNECTOR_TOKEN|WEB_PASSWORD)=' .env ``` ## 4. The first Schulcloud token @@ -406,13 +419,27 @@ Going back works the same way: set the previous commit id, then `pull` and ## Backups +Everything this server stores is a copy of something upstream — with one +exception. **Your own notes are not a copy of anything**, and nothing can +rebuild them. + | What | Needed? | |---|---| | `.env` | **Yes** — it holds every secret. Only ever back it up encrypted. | +| **`schulcloud-mcp_notes`** | **Yes. The only irreplaceable thing here** — a note taken in a lesson cannot be retaken. Small: a school year is a few megabytes. | | Postgres | Optional: a crawl rebuilds it. `docker compose exec postgres pg_dump -U schulcloud schulcloud \| gzip > index.sql.gz` | | `schulcloud-mcp_mirror` | No — re-downloaded by the next crawl. | | `schulcloud-mcp_state` | No — a replaced token, expiring within 30 days anyway. | +```bash +docker run --rm -v schulcloud-mcp_notes:/notes:ro -v "$PWD":/out alpine \ + tar czf /out/notizen-$(date +%F).tar.gz -C /notes . +``` + +If you bind-mounted a directory you already sync ([NOTES.md](NOTES.md)), back +that up instead — and check the sync tool keeps versioned copies rather than +assuming it does. + ## Troubleshooting | Symptom | Likely cause | Fix | @@ -431,7 +458,10 @@ Going back works the same way: set the previous commit id, then `pull` and | Compose rejects `!reset` in `deploy/docker-compose.pi.yml` | Compose older than 2.24 | Update Docker (step 1) | | `WebUntis rejected the server's key` from a untis_* tool | The key was regenerated in WebUntis, or `UNTIS_USER` does not match it | Copy both again from Profil → Freigaben → Untis Mobile, then `docker compose up -d --force-recreate schulcloud-mcp` | | `the server's clock is too far off` from a untis_* tool | The Pi's clock has drifted; the Untis code is time-based | `timedatectl status`, then fix NTP | -| `EACCES` for `/data/state` or `/data/mirror` in the logs | A volume created by an old image, owned by root | `docker compose down`, `docker volume rm schulcloud-mcp_state` (or `_mirror`), `docker compose up -d` | +| `EACCES` for `/data/state`, `/data/mirror` or `/data/notes` in the logs | A volume created by an old image, owned by root | `docker compose down`, `docker volume rm schulcloud-mcp_state` (or `_mirror`) and `docker compose up -d`. **Never delete `_notes`** — back it up, then fix the ownership: `docker run --rm -v schulcloud-mcp_notes:/n alpine chown -R 1000:1000 /n` | +| `/app/` answers 404 | `WEB_PASSWORD` not set, or the container not recreated since it was | `grep ^WEB_PASSWORD= .env`, then `docker compose up -d --force-recreate schulcloud-mcp` | +| The app logs you out constantly | The session cookie is marked `Secure` but the connection is not HTTPS, or Caddy's `X-Forwarded-Proto` is being stripped | Reach it over HTTPS; leave that header alone | +| Notes exist but `search` cannot find them | Only a **full** crawl reads them | `schulcloud refresh --force` | ## Security checklist @@ -451,3 +481,7 @@ Going back works the same way: set the previous commit id, then `pull` and `docker compose up -d --force-recreate schulcloud-mcp`, and re-add the connector with the new header. A leaked secret path is replaced the same way. `MCP_AUTH_TOKEN` stays valid either way. +- `WEB_PASSWORD` is the one credential here that can be guessed, so its length + is what protects the notes and the Schulcloud token. Changing it and + recreating the container logs out every session — that is the revocation + mechanism, and the thing to do if a phone is lost. 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/copy-assets.mjs b/scripts/copy-assets.mjs index 7bd5ff5..430549a 100644 --- a/scripts/copy-assets.mjs +++ b/scripts/copy-assets.mjs @@ -8,7 +8,13 @@ */ import { cp, mkdir } from 'node:fs/promises'; -const assets = [['src/store/migrations', 'dist/store/migrations']]; +const assets = [ + ['src/store/migrations', 'dist/store/migrations'], + // The web app's HTML, CSS and script. Real files rather than strings in a + // module, so an editor and a linter can read them — which only works if the + // build puts them next to the module that serves them. + ['src/http/app', 'dist/http/app'], +]; for (const [from, to] of assets) { await mkdir(to, { recursive: true }); 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..af5186f 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -25,6 +25,15 @@ 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; +// A password of its own, so the web app is exercised and the run can never be +// opened with one from the environment. +const WEB_PASSWORD = `smoke-${randomBytes(16).toString('hex')}`; +process.env.WEB_PASSWORD = WEB_PASSWORD; // The app is bound by this script on an ephemeral port, so config.port is unused. const config = loadConfig(); @@ -480,6 +489,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 +585,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 +663,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', @@ -604,6 +707,123 @@ if (hasUntis) { ); } +console.log('\n== web app =='); +// The one surface here meant for a person rather than a program: a login, the +// day's notes, and the settings page that replaces the Schulcloud token. +{ + const root = `http://127.0.0.1:${port}`; + const jsonHeaders = { 'content-type': 'application/json' }; + + const shell = await fetch(`${root}/app/`); + const shellText = await shell.text(); + check( + 'the app shell is served with a strict content security policy', + shell.ok && + /text\/html/.test(shell.headers.get('content-type') ?? '') && + /default-src 'none'/.test(shell.headers.get('content-security-policy') ?? '') && + /no-store/.test(shell.headers.get('cache-control') ?? ''), + shell.headers.get('content-security-policy')?.slice(0, 40), + ); + check('the shell holds no secret of its own', !shellText.includes(WEB_PASSWORD) && !shellText.includes(TOKEN)); + + const assets = await Promise.all( + ['app.js', 'app.css', 'icon.svg', 'manifest.webmanifest'].map((name) => fetch(`${root}/app/${name}`)), + ); + check('the app\'s assets are served', assets.every((response) => response.ok), assets.map((r) => r.status).join(' ')); + + const anonymousSession = await (await fetch(`${root}/app/session`)).json(); + check('session says "not logged in" rather than failing', anonymousSession.authenticated === false); + + const closed = await fetch(`${root}/api/notes`); + check('/api is closed without a session or a token', closed.status === 401, `got ${closed.status}`); + + const wrong = await fetch(`${root}/app/login`, { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ password: 'not-the-password' }), + }); + check('a wrong password is refused with no detail', wrong.status === 401, `got ${wrong.status}`); + + const login = await fetch(`${root}/app/login`, { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ password: WEB_PASSWORD }), + }); + const setCookie = login.headers.get('set-cookie') ?? ''; + check( + 'logging in sets an HttpOnly, SameSite=Strict session cookie', + login.ok && /HttpOnly/.test(setCookie) && /SameSite=Strict/i.test(setCookie), + setCookie.split(';').slice(1).join(';').trim(), + ); + + const cookie = setCookie.split(';')[0] ?? ''; + const withSession = { cookie }; + + const session = await (await fetch(`${root}/app/session`, { headers: withSession })).json(); + check('the session is recognised', session.authenticated === true); + + const viaSession = await fetch(`${root}/api/notes`, { headers: withSession }); + check('a logged-in browser reaches /api without a token', viaSession.ok, `got ${viaSession.status}`); + + const mcpViaSession = await fetch(`${root}/mcp`, { + method: 'POST', + headers: { ...jsonHeaders, accept: 'application/json, text/event-stream', ...withSession }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }), + }); + // The session is for the app. Nothing in a browser speaks MCP, and a surface + // that is not needed is not offered. + check('the session does not open /mcp', mcpViaSession.status === 401, `got ${mcpViaSession.status}`); + + const tampered = await fetch(`${root}/api/notes`, { headers: { cookie: `${cookie.split('=')[0]}=9999999999999.x.forged` } }); + check('a forged session cookie is refused', tampered.status === 401, `got ${tampered.status}`); + + // The day editor: read the day, write it, read it back. + const day = await (await fetch(`${root}/api/notes/day?date=2026-09-18`, { headers: withSession })).json(); + check( + 'the day route answers with a path, a title and the timetable state', + day.path === '2026/2026-09-18.md' && /2026/.test(day.title) && ['ok', 'off', 'unavailable'].includes(day.timetable), + `${day.title} — timetable ${day.timetable}, ${day.lessons?.length ?? 0} lesson(s)`, + ); + + // A subject no other check uses, so "found by its heading" cannot pass by + // matching the subject note the notes section wrote earlier. + const body = '## 1. Geschichte — 08:00–08:45\n\nWeimarer Republik: Ursachen des Scheiterns.\n'; + const saved = await fetch(`${root}/api/notes/day`, { + method: 'PUT', + headers: { ...jsonHeaders, ...withSession }, + body: JSON.stringify({ date: '2026-09-18', text: body }), + }); + const savedBody = await saved.json(); + check('the day saves', saved.ok && savedBody.path === '2026/2026-09-18.md', `${saved.status}`); + + const conflict = await fetch(`${root}/api/notes/day`, { + method: 'PUT', + headers: { ...jsonHeaders, ...withSession }, + body: JSON.stringify({ date: '2026-09-18', text: 'überschrieben', expectedModifiedAt: '2020-01-01T00:00:00.000Z' }), + }); + check('a save that would clobber a newer version is refused', conflict.status === 409, `got ${conflict.status}`); + + const reread = await (await fetch(`${root}/api/notes/day?date=2026-09-18`, { headers: withSession })).json(); + // Trimmed on both sides: a stored note ends with exactly one newline, which + // is the editor's business and not something to assert on. + check('the refused save changed nothing', reread.text.trim() === body.trim(), reread.text.split('\n')[0]); + + // The lesson heading the page writes has to be the one the index reads back, + // or a day's notes are filed under no subject at all. + const bySubject = await (await fetch(`${root}/api/notes?subject=Geschichte`, { headers: withSession })).json(); + check( + 'a day note is found by a subject only its lesson headings know', + bySubject.count === 1 && bySubject.notes[0]?.path === '2026/2026-09-18.md', + `${bySubject.count} note(s)`, + ); + + const badDate = await fetch(`${root}/api/notes/day?date=2026-02-30`, { headers: withSession }); + check('a date that does not exist is refused', badDate.status === 400, `got ${badDate.status}`); + + const loggedOut = await fetch(`${root}/app/logout`, { method: 'POST', headers: withSession }); + check('logging out clears the cookie', loggedOut.ok && /Max-Age=0/.test(loggedOut.headers.get('set-cookie') ?? '')); +} + console.log('\n== api_get guard rails =='); check('api_get allows /api/ paths', !(await call('api_get', { path: '/api/v3/me' })).isError); check('api_get rejects non-/api path', (await call('api_get', { path: '/etc/passwd' })).isError); @@ -713,6 +933,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..fc6e6e4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -63,6 +63,34 @@ export interface Config { /** How often to re-crawl on a timer. Zero = only on demand. */ crawlIntervalMs: number; + /** + * Password for the web app at `/app` — the notes editor and the settings + * page. Unset = the app is not served at all, by the same rule the untis_* + * tools follow: a login screen no password can open is worse than no page. + * + * A credential, and the only one here a person types: it is hashed at + * startup and the plain value is never compared, stored or logged. + */ + webPassword: string | undefined; + /** + * 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 @@ -162,6 +190,26 @@ function untisConfig(): UntisConfig | undefined { return { server, school, user, secret }; } +/** + * The app password, or undefined when the app is switched off. + * + * A length floor and nothing else: this one is typed by a person on a phone, + * so demanding punctuation would buy little and cost the thing that actually + * matters, which is that they pick something long. The error states the rule + * and never echoes the value. + */ +function webPassword(): string | undefined { + const value = process.env.WEB_PASSWORD; + if (!value) return undefined; + if (value.length < 12) { + throw new Error( + 'WEB_PASSWORD must be at least 12 characters — it is the only thing between the internet and the ' + + 'notes app. A passphrase of three or four words is ideal.', + ); + } + return value; +} + /** Like `int`, but 0 is meaningful (it disables the feature) rather than invalid. */ function intAllowingZero(name: string, fallback: number): number { const raw = process.env[name]?.trim(); @@ -210,6 +258,12 @@ 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. + webPassword: webPassword(), + 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/day-note.ts b/src/core/day-note.ts new file mode 100644 index 0000000..31cde6d --- /dev/null +++ b/src/core/day-note.ts @@ -0,0 +1,117 @@ +import { germanDay, germanWeekday } from './dates.ts'; +import type { UntisLesson, UntisTimetable } from './untis.ts'; + +/** + * A school day as a note: one file, one heading per lesson. + * + * This is where the two systems meet for the third one. WebUntis is the only + * place that knows which lessons a day actually holds — including that the + * third period was cancelled and the fourth is a substitution — so a page that + * asks someone to write up their day can hand them the day already laid out + * instead of an empty box. The headings it writes are the ones + * `subjectFromHeading` reads back, which is what makes each lesson separately + * searchable afterwards. + */ + +export interface DayLesson { + /** The heading text, without its `##`. */ + heading: string; + subject?: string; + start: string; + end: string; + periodId: number; + /** A substitution: worth knowing while writing, since the teacher differs. */ + changed: boolean; +} + +/** `Montag, 15.09.2026` — what a day note is called. */ +export function dayNoteTitle(date: string): string { + return `${germanWeekday(date)}, ${germanDay(date)}`; +} + +/** + * One lesson's heading: `1. DE — 08:00–08:45 · Meier · R 204`. + * + * The subject leads, because that is the part a person scans for and the part + * the parser reads back. Everything after the first `·` is context and may be + * edited away without breaking anything. + */ +export function lessonHeading(lesson: UntisLesson, index: number): string { + const subject = lesson.subjects[0]; + const name = subject?.longName || subject?.name || 'Stunde'; + const teachers = lesson.teachers.map((teacher) => teacher.name).join(', '); + const rooms = lesson.rooms.map((room) => room.name).join(', '); + return [ + `${index + 1}. ${name} — ${lesson.start}–${lesson.end}`, + teachers || undefined, + rooms ? `R ${rooms}` : undefined, + lesson.changed ? 'Vertretung' : undefined, + ] + .filter(Boolean) + .join(' · '); +} + +/** + * The day's lessons, in order, as headings. + * + * Cancelled periods are left out: nothing was taught in them, and a heading + * with nothing under it is worse than no heading. A substitution is kept and + * marked, because it did happen and its teacher is not the usual one. + */ +export function dayLessons(timetable: UntisTimetable, date: string): DayLesson[] { + const day = timetable.days.find((entry) => entry.date === date); + const held = (day?.lessons ?? []).filter((lesson) => !lesson.cancelled); + return held.map((lesson, index) => { + const subject = lesson.subjects[0]; + return { + heading: lessonHeading(lesson, index), + ...(subject?.longName || subject?.name ? { subject: subject!.longName || subject!.name } : {}), + start: lesson.start, + end: lesson.end, + periodId: lesson.periodId, + changed: lesson.changed, + }; + }); +} + +/** + * The starting text for a day's note: a heading per lesson, blank beneath. + * + * Blank rather than prompted — a placeholder line would have to be deleted in + * every lesson of every day, and half of them would survive into the note. + */ +export function dayNoteSkeleton(lessons: DayLesson[]): string { + if (lessons.length === 0) return ''; + return `${lessons.map((lesson) => `## ${lesson.heading}\n`).join('\n')}`; +} + +/** + * The headings a note is missing, for a day whose timetable is known. + * + * Someone who starts a note before the day ends, or whose timetable changed + * after they started, should be able to top it up without losing what they + * wrote — so the page adds what is absent rather than rebuilding the note. + */ +export function missingHeadings(text: string, lessons: DayLesson[]): DayLesson[] { + const present = new Set( + text + .split('\n') + .map((line) => /^##\s+(.*\S)\s*$/.exec(line)?.[1]) + .filter((heading): heading is string => Boolean(heading)) + .map(headingKey), + ); + return lessons.filter((lesson) => !present.has(headingKey(lesson.heading))); +} + +/** + * What makes two headings "the same lesson". + * + * The period number and the subject, ignoring everything a person may have + * rewritten — a heading edited from `2. DE — 08:50–09:35 · Meier` down to + * `2. Deutsch` is still the second period, and adding it again would give the + * day two. + */ +function headingKey(heading: string): string { + const match = /^\s*(\d{1,2})\s*[.)]/.exec(heading); + return match ? `#${match[1]}` : heading.trim().toLowerCase().replace(/\s+/g, ' '); +} diff --git a/src/core/match.ts b/src/core/match.ts index 64b849b..882f0ac 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, noteSections } 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,42 @@ 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) { + // Per lesson where the note has lessons, exactly as the index does it — + // otherwise fresh=true would report "Monday" where the index reports + // "Deutsch, Monday", and the two paths would disagree about the same file. + const sections = noteSections(note); + if (sections.length > 0) { + for (const section of sections) { + const haystack = [section.heading, section.text].filter(Boolean).join('\n'); + if (!matchesAll(haystack, terms)) continue; + hits.push({ + courseId: note.courseId ?? '', + courseTitle: section.subject ?? note.subject ?? 'Notizen', + where: `my own note, ${section.heading}${note.date ? `, ${note.date}` : ''}`, + targetId: note.path, + targetKind: 'note', + snippet: snippet(haystack, terms), + }); + } + continue; + } + + 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..a46610f --- /dev/null +++ b/src/core/notes.ts @@ -0,0 +1,654 @@ +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> = {}; + const lines = block.split(/\r?\n/); + for (const [index, line] of lines.entries()) { + const match = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line.trim()); + if (!match) continue; + const key = match[1]!.toLowerCase(); + let value = unquote(match[2]!.trim()); + // `tags:` followed by indented `- item` lines is how Obsidian and most + // YAML front ends write a list, and reading only the inline `[a, b]` + // form dropped every tag such an editor had written. + if (!value) { + const items = blockList(lines, index); + if (items.length === 0) continue; + value = `[${items.join(', ')}]`; + } + 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 }; +} + +/** Raised when a note changed under an editor that was holding it open. */ +export class NoteConflict extends Error { + readonly path: string; + readonly modifiedAt: string; + + constructor(path: string, modifiedAt: string) { + super(`The note "${path}" was changed by something else since it was loaded.`); + this.name = 'NoteConflict'; + this.path = path; + this.modifiedAt = modifiedAt; + } +} + +/** + * Replaces a note's contents — what an editor does when it saves. + * + * Separate from `writeNote`, which never overwrites: that rule protects + * `add_note` from clobbering a note it did not mean to touch, and it is exactly + * wrong for a page whose whole job is editing the day in front of you. + * + * `expectedModifiedAt` is how the two stay compatible. The notes are a folder + * that may be synced and is certainly open in more than one place — a phone in + * the lesson, a laptop after it — so a save that would overwrite a version the + * editor never saw is refused rather than silently winning. + */ +export async function replaceNote( + root: string, + relative: string, + input: { title: string; text: string; date?: string; subject?: string; courseId?: string; tags?: string[]; source?: string }, + options: { expectedModifiedAt?: string } = {}, +): Promise<NoteDoc> { + const path = normalizeRelative(relative); + const absolute = resolveWithin(root, path); + + const info = await stat(absolute).catch(() => undefined); + if (info && options.expectedModifiedAt) { + // Second resolution: some filesystems and most sync tools do not preserve + // milliseconds, so comparing the full ISO string would report a conflict + // for a file nobody touched. + const seen = Math.floor(new Date(options.expectedModifiedAt).getTime() / 1000); + const actual = Math.floor(info.mtime.getTime() / 1000); + if (Number.isFinite(seen) && actual > seen) throw new NoteConflict(path, info.mtime.toISOString()); + } + + await mkdir(dirname(absolute), { recursive: true }); + await writeFile( + absolute, + renderNote( + { + title: input.title, + ...(input.date ? { date: input.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 readNoteAt(root, path); +} + +/** 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}.`); +} + +// --- sections: one note per school day, one heading per lesson ------------- + +/** + * A `##` section of a note, which for a day note is one lesson. + * + * The shape the notes page writes — one note per school day, titled with the + * date, a heading per timetable lesson, prose and lists and tables beneath — + * is the shape people actually take notes in, and it is the reason this exists. + * Indexing such a note whole would make every hit read "my note, Monday" and + * lose the one thing that makes it findable: which lesson it was. + */ +export interface NoteSection { + /** The heading text, without its `##`. */ + heading: string; + /** The subject read out of the heading, when there is one. */ + subject?: string; + /** The body under the heading, subheadings included. */ + text: string; +} + +/** + * The `##` sections of a note, in order. Empty when it has none — a subject + * note from the import is one piece of prose, and has to stay that way. + * + * Fenced code blocks are skipped, or a `## comment` inside one would split the + * note where nobody wrote a heading. + */ +export function noteSections(note: NoteDoc): NoteSection[] { + const sections: NoteSection[] = []; + let current: { heading: string; lines: string[] } | undefined; + let fence: string | undefined; + + for (const line of note.text.split('\n')) { + const fenceMark = /^\s{0,3}(```+|~~~+)/.exec(line); + if (fenceMark) { + if (!fence) fence = fenceMark[1]![0]; + else if (fenceMark[1]!.startsWith(fence)) fence = undefined; + } + const heading = fence ? null : /^##\s+(.*\S)\s*$/.exec(line); + if (heading) { + if (current) sections.push(toSection(current)); + current = { heading: heading[1]!, lines: [] }; + continue; + } + if (current) current.lines.push(line); + } + if (current) sections.push(toSection(current)); + return sections; +} + +function toSection(raw: { heading: string; lines: string[] }): NoteSection { + const subject = subjectFromHeading(raw.heading); + return { heading: raw.heading, ...(subject ? { subject } : {}), text: raw.lines.join('\n').trim() }; +} + +/** + * The subject a lesson heading names. + * + * Lenient on purpose: the page writes `1. Deutsch — 08:00–08:45 · Meier`, but + * a heading typed by hand is just `Deutsch`, and both have to work. Leading + * period numbers and clock times are stripped, then the subject is whatever + * comes before the first separator. + */ +export function subjectFromHeading(heading: string): string | undefined { + let value = heading.trim(); + value = value.replace(/^\d{1,2}\s*[.)]\s*/, ''); + value = value.replace(/^(\d{1,2}:\d{2}\s*[–—-]\s*\d{1,2}:\d{2}|\d{1,2}:\d{2})\s*[–—·|-]?\s*/, ''); + value = value.split(/\s[–—·|]\s|\s{2,}|\(/)[0]!.trim(); + value = value.replace(/[:,;]+$/, '').trim(); + // A heading that is only a time, a number or punctuation names no subject, + // and guessing one would file the lesson under nonsense. + if (!value || !/[\p{L}]/u.test(value) || value.length > 60) return undefined; + return value; +} + +/** Where a school day's note lives: `2026/2026-09-15.md`. */ +export function dayNotePath(date: string): string { + return `${date.slice(0, 4)}/${date}.md`; +} + +// --- 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'); +} + +/** + * Every subject a note covers: its own, plus one per lesson heading. + * + * A day note has no subject of its own and covers five or six, so asking only + * the frontmatter would make "my Deutsch notes" return nothing at all for + * anyone who writes a note per day. + */ +export function noteSubjects(note: NoteDoc): string[] { + const subjects = note.subject ? [note.subject] : []; + for (const section of noteSections(note)) if (section.subject) subjects.push(section.subject); + return [...new Set(subjects)]; +} + +/** 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 && !noteSubjects(note).some((name) => name.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. + * + * Except a year: notes filed under `2026/` are filed by date, and calling the + * year a subject would put every lesson of a school year under one. + */ +function subjectFromPath(relative: string): string | undefined { + const parts = relative.split('/'); + if (parts.length < 2) return undefined; + const first = parts[0]!; + return /^\d{4}$/.test(first) ? undefined : first; +} + +/** `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; +} + +/** + * The `- item` lines directly under a `key:` with no inline value. + * + * Stops at the first line that is not one, so a list never swallows the key + * after it. + */ +function blockList(lines: string[], from: number): string[] { + const items: string[] = []; + for (let i = from + 1; i < lines.length; i++) { + const item = /^[ \t]+-\s+(.*)$/.exec(lines[i]!); + if (!item) break; + const value = unquote(item[1]!.trim()); + if (value) items.push(value); + } + return items; +} + +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..3a9c9b5 100644 --- a/src/http/api.ts +++ b/src/http/api.ts @@ -12,6 +12,18 @@ import { type FsErrorCode, type WalkEntry, } from '../core/legacy-files.ts'; +import { dayLessons, dayNoteSkeleton, dayNoteTitle, missingHeadings } from '../core/day-note.ts'; +import { isCalendarDate, schoolToday } from '../core/dates.ts'; +import { + dayNotePath, + NoteConflict, + NoteNotFound, + filterNotes, + readNoteAt, + readNotes, + replaceNote, + 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 +40,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 +252,160 @@ 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'); + } + }); + + // --- one school day, as the notes page edits it ------------------------- + // + // The page is a Markdown editor for a single file, so these two are `GET the + // day` and `PUT the day`. What makes them worth their own routes rather than + // the generic ones above is the skeleton: WebUntis is the only thing that + // knows which lessons a day held, and handing someone their day already laid + // out is the difference between a note per day and an empty box. + + router.get('/notes/day', 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 }); + const date = stringParam(req.query.date) ?? schoolToday(); + if (!isCalendarDate(date)) { + return res.status(400).json({ error: 'invalid', message: `Not a date in the calendar: ${date}.` }); + } + try { + const path = dayNotePath(date); + const note = await readNoteAt(root, path).catch((error: unknown) => { + if (error instanceof NoteNotFound) return undefined; + throw error; + }); + + // Never fatal, and reported rather than hidden: without a key, or with + // WebUntis down, the page still has to open — it just cannot offer the + // lessons, and saying so beats an empty skeleton that looks like a day + // with no school. + let lessons: ReturnType<typeof dayLessons> = []; + let timetable: 'ok' | 'off' | 'unavailable' = services.untis ? 'ok' : 'off'; + if (services.untis) { + try { + lessons = dayLessons(await services.untis.timetable(date, date), date); + } catch { + timetable = 'unavailable'; + } + } + + return res.json({ + date, + path, + title: dayNoteTitle(date), + exists: Boolean(note), + text: note?.text ?? '', + modifiedAt: note?.modifiedAt ?? null, + timetable, + lessons, + skeleton: dayNoteSkeleton(lessons), + // What the page would add to a note already started, so "top up the + // day" never rewrites what is there. + missing: note ? dayNoteSkeleton(missingHeadings(note.text, lessons)) : '', + }); + } catch (error) { + return fail(res, error, 'read a day note'); + } + }); + + router.put('/notes/day', express.json({ limit: '2mb' }), 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 { date?: unknown; text?: unknown; expectedModifiedAt?: unknown }; + const date = typeof body.date === 'string' ? body.date : ''; + if (!isCalendarDate(date)) { + return res.status(400).json({ error: 'invalid', message: `Not a date in the calendar: ${date || '(none)'}.` }); + } + if (typeof body.text !== 'string') { + return res.status(400).json({ error: 'invalid', message: 'A day note needs its text.' }); + } + try { + const note = await replaceNote( + root, + dayNotePath(date), + { title: dayNoteTitle(date), text: body.text, date, source: 'notes-page' }, + typeof body.expectedModifiedAt === 'string' ? { expectedModifiedAt: body.expectedModifiedAt } : {}, + ); + return res.json({ path: note.path, modifiedAt: note.modifiedAt, bytes: note.bytes }); + } catch (error) { + // A clash is the caller's to resolve, not a fault: the page shows both + // and lets the person decide, which is the only safe answer when the + // notes folder is synced and open in two places. + if (error instanceof NoteConflict) { + return res.status(409).json({ error: 'conflict', message: error.message, modifiedAt: error.modifiedAt }); + } + return fail(res, error, 'save a day note'); + } + }); + router.get('/token', (_req: Request, res: Response) => { res.json(tokenStatus(services)); }); @@ -352,6 +521,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/http/app-page.ts b/src/http/app-page.ts new file mode 100644 index 0000000..cab93f3 --- /dev/null +++ b/src/http/app-page.ts @@ -0,0 +1,135 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import express, { type Request, type Response, type Router } from 'express'; +import type { Config } from '../config.ts'; +import { createWebAuth, isSecureRequest, sessionAuth, type WebAuth } from './web-auth.ts'; + +/** + * `/app` — the notes app, for a person rather than a program. + * + * Everything else this server exposes is for a machine with a token. This is + * the one surface a human opens on a phone, so it gets a login, a session + * cookie and an interface: the day's notes, and the settings page where the + * Schulcloud token is replaced when it expires. + * + * It is served only when `WEB_PASSWORD` is set, by the same rule as the + * `untis_*` tools and the note tools: an app whose login nothing can open is + * worse than no app, because it looks like a way in. + * + * The assets are files, not strings in this module. They are real HTML, CSS + * and JavaScript that an editor and a linter understand, and the content + * security policy forbids inline script anyway — so the only thing gained by + * embedding them would be a build step that no longer copies them, and the + * only thing lost would be every tool that reads them. + */ + +/** No outside resources at all, and no inline script. Nothing here needs either. */ +const CSP = + "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; " + + "connect-src 'self'; manifest-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; + +const HEADERS: Record<string, string> = { + 'Content-Security-Policy': CSP, + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', + // The app reflects an account's data; a shared phone should not show it from + // the back-forward cache after a logout. + 'Cache-Control': 'no-store', +}; + +const ASSETS: Record<string, { file: string; type: string }> = { + '/': { file: 'index.html', type: 'text/html; charset=utf-8' }, + '/index.html': { file: 'index.html', type: 'text/html; charset=utf-8' }, + '/app.css': { file: 'app.css', type: 'text/css; charset=utf-8' }, + '/app.js': { file: 'app.js', type: 'text/javascript; charset=utf-8' }, + '/icon.svg': { file: 'icon.svg', type: 'image/svg+xml' }, + '/manifest.webmanifest': { file: 'manifest.webmanifest', type: 'application/manifest+json' }, +}; + +/** + * Read once at startup, from next to this module. + * + * `import.meta.dirname` resolves to `src/http` when the tree is run directly + * and `dist/http` after a build, and `scripts/copy-assets.mjs` puts the folder + * in both — so there is one path and no branch on how the server was started. + */ +const assetRoot = join(import.meta.dirname, 'app'); +const cache = new Map<string, Buffer>(); + +function asset(file: string): Buffer { + let bytes = cache.get(file); + if (!bytes) { + bytes = readFileSync(join(assetRoot, file)); + cache.set(file, bytes); + } + return bytes; +} + +export interface AppSurface { + router: Router; + /** The gate `/api` also accepts, so the app's own fetches need no token. */ + auth: WebAuth; +} + +export function createAppRouter(config: Config): AppSurface | undefined { + const auth = createWebAuth(config.webPassword); + if (!auth.enabled) return undefined; + + const router = express.Router(); + const requireSession = sessionAuth(auth); + + router.use((_req: Request, res: Response, next) => { + for (const [name, value] of Object.entries(HEADERS)) res.setHeader(name, value); + next(); + }); + + // The shell is public: it is the login screen, and it holds nothing. Every + // byte of data it goes on to show comes from /api, behind the session. + router.get(/^\/(index\.html|app\.css|app\.js|icon\.svg|manifest\.webmanifest)?$/, (req: Request, res: Response) => { + const entry = ASSETS[req.path] ?? ASSETS['/']!; + res.type(entry.type).send(asset(entry.file)); + }); + + router.post('/login', express.json({ limit: '4kb' }), (req: Request, res: Response) => { + const password = (req.body as { password?: unknown } | undefined)?.password; + if (typeof password !== 'string' || password.length === 0) { + return res.status(400).json({ error: 'invalid', message: 'Passwort fehlt.' }); + } + // The address is the rate-limit key. Behind Caddy every request comes from + // the proxy, so the forwarded address is what distinguishes callers; it is + // spoofable by anyone who can reach this process directly, which on this + // deployment is nobody. + const from = (req.get('x-forwarded-for') ?? '').split(',')[0]?.trim() || req.ip || 'unknown'; + const result = auth.check(password, from); + if (!result.ok) { + if (result.retryAfterSeconds !== undefined) { + res.setHeader('Retry-After', String(result.retryAfterSeconds)); + return res.status(429).json({ + error: 'too_many_attempts', + message: `Zu viele Fehlversuche. In ${Math.ceil(result.retryAfterSeconds / 60)} Minute(n) erneut versuchen.`, + }); + } + // Deliberately no detail, and the same shape for every miss. + return res.status(401).json({ error: 'unauthorized' }); + } + res.setHeader('Set-Cookie', auth.cookie(auth.mint(), { secure: isSecureRequest(req) })); + return res.json({ authenticated: true }); + }); + + router.post('/logout', (req: Request, res: Response) => { + res.setHeader('Set-Cookie', auth.clearCookie({ secure: isSecureRequest(req) })); + return res.json({ authenticated: false }); + }); + + // Always 200: "are you logged in" is not itself a protected question, and a + // 401 here would make the first load of the login screen look like an error. + router.get('/session', (req: Request, res: Response) => { + return res.json({ authenticated: auth.verify(req.get('cookie')) }); + }); + + // Anything else under /app needs the session — there is nothing else to + // serve, but a 404 that leaks the shape of the tree is still a 404 too many. + router.use(requireSession, (_req: Request, res: Response) => res.status(404).json({ error: 'not_found' })); + + return { router, auth }; +} diff --git a/src/http/app/app.css b/src/http/app/app.css new file mode 100644 index 0000000..a0bca6c --- /dev/null +++ b/src/http/app/app.css @@ -0,0 +1,188 @@ +/* + * The app is used with one thumb, in a lesson, on a phone that may be at 10%. + * Everything below follows from that: one column, large touch targets, the + * editor taking every pixel that is not navigation, and no webfont — the CSP + * forbids outside resources anyway, and a font that has not loaded is a blank + * screen in a classroom with no signal. + */ + +:root { + color-scheme: light dark; + --bg: #ffffff; + --fg: #1f2328; + --muted: #656d76; + --line: #d0d7de; + --accent: #1f6feb; + --ok: #1a7f37; + --error: #cf222e; + --warn: #9a6700; + --card: #f6f8fa; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d1117; + --fg: #e6edf3; + --muted: #8b949e; + --line: #30363d; + --accent: #4493f8; + --ok: #3fb950; + --error: #f85149; + --warn: #d29922; + --card: #161b22; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + /* Fills the viewport on a phone, where 100vh lies about the toolbar. */ + min-height: 100dvh; + display: flex; + flex-direction: column; + padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); +} + +.screen { display: flex; flex-direction: column; flex: 1; min-height: 0; } +[hidden] { display: none !important; } + +/* --- chrome ------------------------------------------------------------ */ + +header { border-bottom: 1px solid var(--line); } + +.tabs { display: flex; } + +.tab { + flex: 1; + padding: 0.9rem 0.5rem; + border: 0; + border-bottom: 2px solid transparent; + background: none; + color: var(--muted); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.tab[aria-current="page"] { color: var(--fg); border-bottom-color: var(--accent); } + +.view { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0.75rem; gap: 0.5rem; } + +/* --- the day bar ------------------------------------------------------- */ + +.daybar { display: flex; align-items: center; gap: 0.5rem; } + +.daybar button { + flex: 0 0 auto; + width: 2.75rem; + height: 2.75rem; + font-size: 1.5rem; + line-height: 1; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--card); + color: var(--fg); + cursor: pointer; +} + +.daybar-centre { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 0.15rem; } +.daybar-centre strong { font-size: 1.05rem; } +.daybar-centre input { border: 0; background: none; color: var(--muted); font: inherit; font-size: 0.85rem; } + +/* --- the editor -------------------------------------------------------- */ + +textarea { + flex: 1; + min-height: 12rem; + width: 100%; + padding: 0.75rem; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--bg); + color: var(--fg); + /* Monospace: the notes are Markdown, and headings and list markers have to + line up to be read back as structure. */ + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.95rem; + line-height: 1.5; + resize: none; +} + +.actions { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; } + +button { + padding: 0.65rem 1rem; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--card); + color: var(--fg); + font: inherit; + cursor: pointer; +} + +button:disabled { opacity: 0.5; cursor: default; } + +#save, #login-form button, #token-form button { + background: var(--accent); + border-color: var(--accent); + color: #ffffff; + font-weight: 600; +} + +/* --- text -------------------------------------------------------------- */ + +.status { margin: 0; color: var(--muted); font-size: 0.85rem; min-height: 1.2em; } +.hint { color: var(--muted); font-size: 0.85rem; } +.ok { color: var(--ok); } +.error { color: var(--error); margin: 0.5rem 0 0; } +.warn { color: var(--warn); } + +.conflict { + margin: 0; + padding: 0.6rem 0.75rem; + border: 1px solid var(--warn); + border-radius: 0.5rem; + color: var(--warn); + font-size: 0.9rem; +} + +/* --- cards (login, settings) ------------------------------------------- */ + +.card { + margin: 0.75rem; + padding: 1rem; + border: 1px solid var(--line); + border-radius: 0.75rem; + background: var(--card); +} + +.card h1, .card h2 { margin-top: 0; font-size: 1.15rem; } + +label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 600; font-size: 0.9rem; } + +input[type="password"], input[type="text"] { + width: 100%; + padding: 0.7rem; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--bg); + color: var(--fg); + font: inherit; +} + +#login { justify-content: center; } +#login .card { width: min(24rem, 100%); align-self: center; } +#login button { width: 100%; margin-top: 1rem; } + +.steps { margin: 0.5rem 0; padding-left: 1.1rem; color: var(--muted); font-size: 0.85rem; line-height: 1.5; } +.steps code { font-family: ui-monospace, monospace; } + +#token-form button, #logout { margin-top: 0.75rem; } + +dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 0.35rem 0.75rem; font-size: 0.9rem; } +dt { color: var(--muted); } +dd { margin: 0; } diff --git a/src/http/app/app.js b/src/http/app/app.js new file mode 100644 index 0000000..4040f31 --- /dev/null +++ b/src/http/app/app.js @@ -0,0 +1,490 @@ +'use strict'; + +/* + * The notes app. + * + * One school day is one note, one lesson is one `##` heading, and the server + * builds the headings from WebUntis — so opening the app during a free period + * gives you the day already laid out rather than an empty box. That shape is + * also what makes each lesson separately searchable afterwards, which is the + * whole reason the notes are worth writing here rather than in Notes.app. + * + * Three rules this file exists to honour: + * + * - **Never lose what was typed.** Every keystroke goes to localStorage, and a + * draft that is newer than the server's copy survives a dead connection, a + * locked phone and a closed tab. A note taken in a lesson cannot be retaken. + * - **Never silently overwrite.** Saves carry the modification time the editor + * loaded; the server refuses one that would clobber a version this editor + * never saw, and the banner then makes it the person's decision. + * - **Say what state it is in.** "Gespeichert 14:02", "Nicht gespeichert", + * "Offline — lokal gesichert". A silent editor over a flaky connection is + * indistinguishable from one that is losing your work. + */ + +const AUTOSAVE_MS = 2500; +const DRAFT_PREFIX = 'schulcloud-mcp/draft/'; + +const ui = { + login: document.getElementById('login'), + loginForm: document.getElementById('login-form'), + password: document.getElementById('password'), + loginError: document.getElementById('login-error'), + app: document.getElementById('app'), + tabNotes: document.getElementById('tab-notes'), + tabSettings: document.getElementById('tab-settings'), + viewNotes: document.getElementById('view-notes'), + viewSettings: document.getElementById('view-settings'), + prev: document.getElementById('prev'), + next: document.getElementById('next'), + dayTitle: document.getElementById('day-title'), + dayDate: document.getElementById('day-date'), + dayStatus: document.getElementById('day-status'), + conflict: document.getElementById('day-conflict'), + editor: document.getElementById('editor'), + save: document.getElementById('save'), + fill: document.getElementById('fill'), + lessonsHint: document.getElementById('lessons-hint'), + tokenState: document.getElementById('token-state'), + tokenForm: document.getElementById('token-form'), + jwt: document.getElementById('jwt'), + tokenResult: document.getElementById('token-result'), + serverState: document.getElementById('server-state'), + logout: document.getElementById('logout'), +}; + +/** Everything about the day currently open. */ +const day = { + date: today(), + path: '', + /** The server's modification time for the loaded note, or null if there is none. */ + modifiedAt: null, + /** The text as the server last confirmed it, to tell "dirty" from "saved". */ + saved: '', + /** Headings the timetable has and the note does not. */ + missing: '', + dirty: false, + conflicted: false, + timer: 0, +}; + +// --- plumbing ------------------------------------------------------------ + +async function api(path, options) { + const response = await fetch(path, { + credentials: 'same-origin', + ...options, + headers: { accept: 'application/json', ...(options && options.headers) }, + }); + if (response.status === 401) { + showLogin(); + throw new Error('unauthorized'); + } + let body = null; + try { + body = await response.json(); + } catch (error) { + body = null; + } + if (!response.ok) { + const failure = new Error((body && (body.message || body.error)) || 'HTTP ' + response.status); + failure.status = response.status; + failure.body = body; + throw failure; + } + return body; +} + +function json(method, path, payload) { + return api(path, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }); +} + +function today() { + // The device's own date. The server keeps school dates in Europe/Berlin, but + // the phone in the lesson is in that timezone by definition. + const now = new Date(); + return [now.getFullYear(), pad(now.getMonth() + 1), pad(now.getDate())].join('-'); +} + +function pad(value) { + return String(value).padStart(2, '0'); +} + +function shiftDate(date, days) { + // Noon, so a daylight-saving change cannot push the result onto the + // neighbouring day. + const at = new Date(date + 'T12:00:00'); + at.setDate(at.getDate() + days); + return [at.getFullYear(), pad(at.getMonth() + 1), pad(at.getDate())].join('-'); +} + +function clock() { + const now = new Date(); + return pad(now.getHours()) + ':' + pad(now.getMinutes()); +} + +// --- drafts: the safety net --------------------------------------------- + +function draftKey(date) { + return DRAFT_PREFIX + date; +} + +function saveDraft() { + try { + localStorage.setItem(draftKey(day.date), JSON.stringify({ text: ui.editor.value, at: Date.now() })); + } catch (error) { + // A full or disabled localStorage must not break typing; the server copy + // is still the real one. + } +} + +function readDraft(date) { + try { + const raw = localStorage.getItem(draftKey(date)); + return raw ? JSON.parse(raw) : null; + } catch (error) { + return null; + } +} + +function clearDraft(date) { + try { + localStorage.removeItem(draftKey(date)); + } catch (error) { + // Nothing to do: a stale draft is only ever offered, never forced. + } +} + +// --- the day ------------------------------------------------------------- + +function setStatus(message, kind) { + ui.dayStatus.textContent = message; + ui.dayStatus.className = 'status' + (kind ? ' ' + kind : ''); +} + +async function loadDay(date) { + // Anything unsaved goes to the draft before the view moves, or switching + // days would be a way to lose a lesson. + if (day.dirty) saveDraft(); + window.clearTimeout(day.timer); + + day.date = date; + day.conflicted = false; + ui.conflict.hidden = true; + ui.dayDate.value = date; + ui.editor.disabled = true; + setStatus('Wird geladen …'); + + let info; + try { + info = await api('/api/notes/day?date=' + encodeURIComponent(date)); + } catch (error) { + if (error.message === 'unauthorized') return; + ui.dayTitle.textContent = date; + // A reply with a status is the server saying no — most often that it keeps + // no notes at all — and reporting that as "offline" would send someone + // looking at their signal instead of at NOTES_DIR. + if (error.status) { + ui.editor.value = ''; + ui.editor.disabled = true; + setStatus(error.message, 'error'); + ui.lessonsHint.textContent = ''; + return; + } + // No status: the request never arrived. Fall back to whatever this device + // has, rather than an empty editor that looks like a day with no notes. + const draft = readDraft(date); + ui.editor.disabled = false; + ui.editor.value = draft ? draft.text : ''; + day.saved = ''; + day.modifiedAt = null; + day.dirty = Boolean(draft); + setStatus( + draft ? 'Offline — lokale Fassung, nicht gespeichert.' : 'Offline — keine Verbindung zum Server.', + 'warn', + ); + return; + } + + day.path = info.path; + day.modifiedAt = info.modifiedAt; + day.missing = info.missing || ''; + ui.dayTitle.textContent = info.title; + + const server = info.exists ? info.text : info.skeleton; + const draft = readDraft(date); + // A draft only wins when it differs from what the server holds; otherwise it + // is just the last save echoed back and offering it would be noise. + const useDraft = draft && draft.text !== server && draft.text.trim() !== ''; + + ui.editor.value = useDraft ? draft.text : server; + ui.editor.disabled = false; + day.saved = info.exists ? info.text : ''; + day.dirty = ui.editor.value !== day.saved; + + if (useDraft) { + setStatus('Lokale, noch nicht gespeicherte Fassung wiederhergestellt.', 'warn'); + } else if (info.exists) { + setStatus('Gespeichert.'); + } else if (info.skeleton) { + setStatus('Neuer Tag — Stunden aus WebUntis eingetragen.'); + } else { + setStatus('Neuer Tag.'); + } + + describeLessons(info); + ui.fill.hidden = !day.missing; +} + +function describeLessons(info) { + if (info.timetable === 'off') { + ui.lessonsHint.textContent = 'Ohne WebUntis-Schlüssel: Überschriften selbst anlegen.'; + return; + } + if (info.timetable === 'unavailable') { + ui.lessonsHint.textContent = 'WebUntis nicht erreichbar — Stunden fehlen.'; + return; + } + const count = (info.lessons || []).length; + ui.lessonsHint.textContent = count === 0 ? 'Kein Unterricht an diesem Tag.' : count + ' Stunde(n) laut Stundenplan.'; +} + +function markDirty() { + day.dirty = ui.editor.value !== day.saved; + saveDraft(); + if (day.conflicted) return; + if (day.dirty) setStatus('Nicht gespeichert …'); + window.clearTimeout(day.timer); + day.timer = window.setTimeout(() => void saveDay(true), AUTOSAVE_MS); +} + +async function saveDay(automatic) { + window.clearTimeout(day.timer); + if (!day.dirty && automatic) return; + const text = ui.editor.value; + setStatus('Wird gespeichert …'); + + try { + const result = await json('PUT', '/api/notes/day', { + date: day.date, + text, + // Absent for a note that does not exist yet: there is nothing to clash + // with, and sending null would look like "I saw no version". + ...(day.modifiedAt ? { expectedModifiedAt: day.modifiedAt } : {}), + }); + day.saved = text; + day.modifiedAt = result.modifiedAt; + day.dirty = false; + day.conflicted = false; + ui.conflict.hidden = true; + clearDraft(day.date); + setStatus('Gespeichert ' + clock() + '.', 'ok'); + } catch (error) { + if (error.message === 'unauthorized') return; + if (error.status === 409) { + // Stop autosaving: every further attempt would fail the same way, and + // the choice of which version wins is not ours to make. + day.conflicted = true; + ui.conflict.hidden = false; + ui.conflict.textContent = + 'Diese Notiz wurde anderswo geändert, seit sie hier geöffnet wurde. ' + + '„Neu laden" verwirft, was hier steht; „Trotzdem speichern" überschreibt die andere Fassung. ' + + 'Deine Fassung ist lokal gesichert.'; + ensureConflictButtons(); + setStatus('Nicht gespeichert — Konflikt.', 'error'); + return; + } + if (error.status === 403) { + setStatus('Der Server nimmt keine Änderungen an (NOTES_READONLY).', 'error'); + return; + } + setStatus('Nicht gespeichert — ' + error.message + '. Lokal gesichert.', 'error'); + } +} + +/** The two ways out of a conflict, added once and only when one happens. */ +function ensureConflictButtons() { + if (document.getElementById('conflict-reload')) return; + const reload = document.createElement('button'); + reload.id = 'conflict-reload'; + reload.type = 'button'; + reload.textContent = 'Neu laden'; + reload.addEventListener('click', () => { + clearDraft(day.date); + void loadDay(day.date); + }); + + const force = document.createElement('button'); + force.id = 'conflict-force'; + force.type = 'button'; + force.textContent = 'Trotzdem speichern'; + force.addEventListener('click', () => { + day.modifiedAt = null; + day.conflicted = false; + ui.conflict.hidden = true; + void saveDay(false); + }); + + ui.conflict.append(document.createElement('br'), reload, document.createTextNode(' '), force); +} + +// --- settings ------------------------------------------------------------ + +async function loadSettings() { + ui.tokenState.textContent = 'Wird geladen …'; + try { + const info = await api('/api/token'); + const budget = info.keepalive && info.keepalive.budgetSeconds; + ui.tokenState.textContent = + 'Noch ' + info.daysLeft + ' Tag(e) gültig' + + (budget ? ', Sitzung noch ' + Math.round(budget / 60) + ' min' : '') + + ' (' + info.source + ').'; + ui.tokenState.className = 'status' + (info.daysLeft <= 3 ? ' warn' : ''); + } catch (error) { + if (error.message === 'unauthorized') return; + ui.tokenState.textContent = 'Token-Status nicht lesbar: ' + error.message; + ui.tokenState.className = 'status error'; + } + + ui.serverState.replaceChildren(); + try { + const status = await api('/api/status'); + addFact('Index', status.crawlId ? 'Stand ' + status.crawlId + ', ' + status.nodes + ' Einträge' : 'leer'); + addFact('Dateien', status.files + ' (' + status.extracted + ' mit Text)'); + if (status.indexer && status.indexer.running) addFact('Gerade', 'Durchlauf läuft'); + } catch (error) { + addFact('Index', 'nicht verfügbar'); + } + try { + const notes = await api('/api/notes?limit=1'); + addFact('Notizen', notes.count + ' · ' + notes.root + (notes.writable ? '' : ' (schreibgeschützt)')); + } catch (error) { + addFact('Notizen', 'nicht verfügbar'); + } +} + +function addFact(term, value) { + const dt = document.createElement('dt'); + dt.textContent = term; + const dd = document.createElement('dd'); + dd.textContent = value; + ui.serverState.append(dt, dd); +} + +// --- views --------------------------------------------------------------- + +function showLogin() { + ui.app.hidden = true; + ui.login.hidden = false; + ui.password.focus(); +} + +function showApp() { + ui.login.hidden = true; + ui.app.hidden = false; +} + +function showTab(name) { + const notes = name !== 'settings'; + ui.viewNotes.hidden = !notes; + ui.viewSettings.hidden = notes; + ui.tabNotes.setAttribute('aria-current', notes ? 'page' : 'false'); + ui.tabSettings.setAttribute('aria-current', notes ? 'false' : 'page'); + if (!notes) void loadSettings(); +} + +// --- wiring -------------------------------------------------------------- + +ui.loginForm.addEventListener('submit', async (event) => { + event.preventDefault(); + ui.loginError.textContent = ''; + try { + await json('POST', '/app/login', { password: ui.password.value }); + ui.password.value = ''; + showApp(); + await loadDay(day.date); + } catch (error) { + ui.loginError.textContent = + error.status === 429 ? 'Zu viele Versuche. ' + error.message : 'Passwort falsch.'; + } +}); + +ui.logout.addEventListener('click', async () => { + // The draft stays: logging out is not the same as discarding a lesson. + await json('POST', '/app/logout', {}).catch(() => {}); + showLogin(); +}); + +ui.tabNotes.addEventListener('click', () => showTab('notes')); +ui.tabSettings.addEventListener('click', () => showTab('settings')); + +ui.prev.addEventListener('click', () => void loadDay(shiftDate(day.date, -1))); +ui.next.addEventListener('click', () => void loadDay(shiftDate(day.date, 1))); +ui.dayDate.addEventListener('change', () => { + if (ui.dayDate.value) void loadDay(ui.dayDate.value); +}); + +ui.editor.addEventListener('input', markDirty); +ui.save.addEventListener('click', () => void saveDay(false)); + +ui.fill.addEventListener('click', () => { + // Appended, never merged into place: the person's own text is not something + // to reorder, and a heading in the wrong order is trivial to move. + const separator = ui.editor.value.trim() ? '\n\n' : ''; + ui.editor.value = ui.editor.value.replace(/\s*$/, '') + separator + day.missing; + day.missing = ''; + ui.fill.hidden = true; + markDirty(); +}); + +// A phone locking, the app going to the background, or the tab closing: all of +// them end the session without a "save" ever being pressed. +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden' && day.dirty) { + saveDraft(); + if (!day.conflicted) void saveDay(true); + } +}); + +window.addEventListener('beforeunload', (event) => { + if (!day.dirty) return; + saveDraft(); + event.preventDefault(); + event.returnValue = ''; +}); + +ui.tokenForm.addEventListener('submit', async (event) => { + event.preventDefault(); + ui.tokenResult.textContent = 'Wird geprüft …'; + ui.tokenResult.className = 'status'; + try { + const result = await json('PUT', '/api/token', { jwt: ui.jwt.value }); + ui.jwt.value = ''; + ui.tokenResult.textContent = result.changed + ? 'Ersetzt. Noch ' + result.daysLeft + ' Tag(e) gültig.' + (result.persisted ? '' : ' (Nicht dauerhaft gespeichert.)') + : 'Das ist der Token, der bereits benutzt wird.'; + ui.tokenResult.className = 'status ok'; + void loadSettings(); + } catch (error) { + if (error.message === 'unauthorized') return; + ui.tokenResult.textContent = error.message; + ui.tokenResult.className = 'status error'; + } +}); + +// --- start --------------------------------------------------------------- + +void (async () => { + try { + const session = await api('/app/session'); + if (!session.authenticated) { + showLogin(); + return; + } + } catch (error) { + showLogin(); + return; + } + showApp(); + await loadDay(day.date); +})(); diff --git a/src/http/app/icon.svg b/src/http/app/icon.svg new file mode 100644 index 0000000..12a1ea9 --- /dev/null +++ b/src/http/app/icon.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Notizen"> + <rect width="64" height="64" rx="14" fill="#1f6feb"/> + <g fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round"> + <path d="M18 20h28M18 32h28M18 44h18"/> + </g> +</svg> diff --git a/src/http/app/index.html b/src/http/app/index.html new file mode 100644 index 0000000..a5cf76f --- /dev/null +++ b/src/http/app/index.html @@ -0,0 +1,87 @@ +<!doctype html> +<html lang="de"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> +<meta name="theme-color" content="#1f6feb"> +<title>Schulcloud — Notizen + + + + + + + + + + + + + + + diff --git a/src/http/app/manifest.webmanifest b/src/http/app/manifest.webmanifest new file mode 100644 index 0000000..6f605f4 --- /dev/null +++ b/src/http/app/manifest.webmanifest @@ -0,0 +1,14 @@ +{ + "name": "Schulcloud Notizen", + "short_name": "Notizen", + "description": "Notizen zum Schultag, Stunde für Stunde.", + "start_url": "./", + "scope": "./", + "display": "standalone", + "orientation": "portrait", + "background_color": "#ffffff", + "theme_color": "#1f6feb", + "icons": [ + { "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" } + ] +} diff --git a/src/http/auth.ts b/src/http/auth.ts index 701b1a3..3b34bc2 100644 --- a/src/http/auth.ts +++ b/src/http/auth.ts @@ -12,11 +12,25 @@ import type { NextFunction, Request, Response } from 'express'; * * A route can accept more than one token: `/mcp` also takes the connector * token claude.ai stores, which `/api` refuses. + * + * `alsoAccept` is the other kind of caller: a person logged into the web app, + * carrying a session cookie rather than a token. `/api` takes it because the + * app is built on `/api` and a session *is* the user; `/mcp` does not, because + * nothing in a browser speaks MCP and a surface not needed is a surface not + * offered. */ -export function bearerAuth(accepted: string | string[]) { +export function bearerAuth(accepted: string | string[], alsoAccept?: (req: Request) => boolean) { const expected = (Array.isArray(accepted) ? accepted : [accepted]).map((token) => Buffer.from(token, 'utf8')); return function authenticate(req: Request, res: Response, next: NextFunction): void { + // A logged-in browser instead of a token. Checked first because the app's + // own fetches carry no Authorization header at all, and running them + // through the token comparison would only waste it. + if (alsoAccept?.(req)) { + next(); + return; + } + const presented = extractToken(req.get('authorization'), req.get('x-api-key') ?? req.get('x-auth-token')); // Every token is compared even after a match, so the timing does not // tell which one was presented. diff --git a/src/http/server.ts b/src/http/server.ts index 807223f..1eb2cf3 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -6,6 +6,7 @@ import type { Config } from '../config.ts'; import { createServer } from '../mcp/server.ts'; import type { Services } from '../services.ts'; import { createApiRouter } from './api.ts'; +import { createAppRouter } from './app-page.ts'; import { bearerAuth, pathSecret } from './auth.ts'; import { tokenPage, tokenScript } from './token-page.ts'; @@ -61,9 +62,17 @@ export function createHttpApp(config: Config, services?: Services): express.Expr // connector token opens /mcp alone. claude.ai stores it as a request header, // and a credential held by a third party should reach the read-only tools, // not /api, which can replace the Schulcloud token and stream the file mirror. + // The web app, when a password is configured. Mounted before the token gate + // so its login screen is reachable without one — it is the thing that issues + // the session everything else then accepts. + const appSurface = services ? createAppRouter(config) : undefined; + if (appSurface) app.use('/app', appSurface.router); + + const loggedIn = appSurface ? (req: Request) => appSurface.auth.verify(req.get('cookie')) : undefined; + if (config.authToken) { app.use(MCP_PATH, bearerAuth(config.connectorToken ? [config.authToken, config.connectorToken] : config.authToken)); - app.use(API_PATH, bearerAuth(config.authToken)); + app.use(API_PATH, bearerAuth(config.authToken, loggedIn)); } else { console.warn( '[schulcloud-mcp] MCP_AUTH_TOKEN is not set — the endpoint is UNAUTHENTICATED. ' + diff --git a/src/http/web-auth.ts b/src/http/web-auth.ts new file mode 100644 index 0000000..ca332c7 --- /dev/null +++ b/src/http/web-auth.ts @@ -0,0 +1,197 @@ +import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'; +import type { NextFunction, Request, Response } from 'express'; + +/** + * A login for the web app, as opposed to a token for a machine. + * + * Everything else here authenticates a program: the CLI and Claude send a + * bearer token they were configured with. A person on a phone cannot be asked + * to paste a 64-character token into a browser every time they want to write + * down what happened in German, so the app gets a password and a session + * cookie — which is a different credential with a different lifetime, not a + * second way to present the same one. + * + * What that buys and what it costs: + * + * - **The password is never stored, compared or logged in the clear.** It is + * put through scrypt at startup and only the hash is kept; a login hashes + * the attempt and compares in constant time. + * - **The session key is derived from the password**, so changing the password + * invalidates every session that exists — which is the behaviour anyone + * changing a password expects, and it needs no second secret and no storage. + * - **The cookie is HttpOnly and SameSite=Strict**, so no script can read it + * and no other site can cause a request that carries it. That is what stands + * in for CSRF tokens here. + * - **Login is rate-limited per address**, because the endpoint is on the + * internet and a password is guessable in a way a 32-byte token is not. The + * scrypt cost is itself a brute-force defence and, without a limiter, a + * denial-of-service vector — so the limiter is not optional. + */ + +export const SESSION_COOKIE = 'sc_app'; + +/** How long a login lasts. Long, because the alternative is logging in during a lesson. */ +const SESSION_TTL_MS = 30 * 24 * 60 * 60_000; + +/** scrypt parameters. N=16384 is ~50ms here — slow enough to matter, fast enough to log in. */ +const SCRYPT = { N: 16_384, r: 8, p: 1, keylen: 32 }; + +/** Failed logins allowed from one address before it has to wait. */ +const MAX_ATTEMPTS = 8; +const ATTEMPT_WINDOW_MS = 15 * 60_000; + +export interface WebAuth { + /** True when a password is configured at all; without one the app is not served. */ + readonly enabled: boolean; + check(password: string, from: string): { ok: boolean; retryAfterSeconds?: number }; + mint(): string; + verify(cookieHeader: string | undefined): boolean; + cookie(value: string, options: { secure: boolean }): string; + clearCookie(options: { secure: boolean }): string; +} + +/** + * Builds the app's authenticator from the configured password. + * + * The salt is fixed rather than random because the hash is never stored: it + * lives in this process only, and a random salt would merely mean the same + * password produced a different session key on every restart — logging + * everyone out whenever the Pi reboots. + */ +export function createWebAuth(password: string | undefined): WebAuth { + if (!password) { + return { + enabled: false, + check: () => ({ ok: false }), + mint: () => '', + verify: () => false, + cookie: () => '', + clearCookie: () => '', + }; + } + + const verifier = scryptSync(password, 'schulcloud-mcp/app/verifier', SCRYPT.keylen, SCRYPT); + // A separate derivation, so a session cookie can never be used to test a + // password guess offline against the verifier. + const sessionKey = scryptSync(password, 'schulcloud-mcp/app/session', SCRYPT.keylen, SCRYPT); + const attempts = new Map(); + + return { + enabled: true, + + check(presented: string, from: string) { + const now = Date.now(); + const record = attempts.get(from); + if (record && now - record.first > ATTEMPT_WINDOW_MS) attempts.delete(from); + + const current = attempts.get(from); + if (current && current.count >= MAX_ATTEMPTS) { + return { ok: false, retryAfterSeconds: Math.ceil((ATTEMPT_WINDOW_MS - (now - current.first)) / 1000) }; + } + + const hashed = scryptSync(presented, 'schulcloud-mcp/app/verifier', SCRYPT.keylen, SCRYPT); + if (timingSafeEqual(hashed, verifier)) { + attempts.delete(from); + return { ok: true }; + } + attempts.set(from, { count: (current?.count ?? 0) + 1, first: current?.first ?? now }); + return { ok: false }; + }, + + mint(): string { + const expires = Date.now() + SESSION_TTL_MS; + // A nonce so two logins never mint the same cookie; nothing reads it + // back, it only keeps the value unique. + const nonce = randomBytes(9).toString('base64url'); + const body = `${expires}.${nonce}`; + return `${body}.${sign(body, sessionKey)}`; + }, + + verify(cookieHeader: string | undefined): boolean { + const value = readCookie(cookieHeader, SESSION_COOKIE); + if (!value) return false; + const cut = value.lastIndexOf('.'); + if (cut <= 0) return false; + const body = value.slice(0, cut); + const presented = Buffer.from(value.slice(cut + 1), 'utf8'); + const expected = Buffer.from(sign(body, sessionKey), 'utf8'); + if (presented.length !== expected.length || !timingSafeEqual(presented, expected)) return false; + const expires = Number(body.split('.')[0]); + return Number.isFinite(expires) && expires > Date.now(); + }, + + cookie(value: string, options: { secure: boolean }): string { + return [ + `${SESSION_COOKIE}=${value}`, + 'Path=/', + 'HttpOnly', + // Strict, not Lax: nothing links into this app from elsewhere, and + // Strict is what removes cross-site requests as a category. + 'SameSite=Strict', + `Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`, + options.secure ? 'Secure' : undefined, + ] + .filter(Boolean) + .join('; '); + }, + + clearCookie(options: { secure: boolean }): string { + return [ + `${SESSION_COOKIE}=`, + 'Path=/', + 'HttpOnly', + 'SameSite=Strict', + 'Max-Age=0', + options.secure ? 'Secure' : undefined, + ] + .filter(Boolean) + .join('; '); + }, + }; +} + +function sign(body: string, key: Buffer): string { + return createHmac('sha256', key).update(body).digest('base64url'); +} + +/** One cookie out of a `Cookie:` header, without a dependency. */ +export function readCookie(header: string | undefined, name: string): string | undefined { + if (!header) return undefined; + for (const part of header.split(';')) { + const eq = part.indexOf('='); + if (eq === -1) continue; + if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim(); + } + return undefined; +} + +/** + * True when the request reached us over TLS. + * + * Behind Caddy the hop to this process is plain HTTP, so the header it sets is + * the only evidence — and marking the cookie Secure on a connection that is + * not would make it vanish, which looks exactly like a broken login. + */ +export function isSecureRequest(req: Request): boolean { + const forwarded = req.get('x-forwarded-proto'); + if (forwarded) return forwarded.split(',')[0]!.trim() === 'https'; + return req.protocol === 'https'; +} + +/** Gate for the app's own pages and for `/api` when the caller is a browser. */ +export function sessionAuth(auth: WebAuth) { + return function requireSession(req: Request, res: Response, next: NextFunction): void { + if (auth.verify(req.get('cookie'))) { + next(); + return; + } + // HTML gets the login screen, fetch() gets a 401 it can act on. Answering + // a fetch with a redirect to a page would hand the caller a chunk of HTML + // it cannot use and no way to tell what went wrong. + if (req.method === 'GET' && (req.get('accept') ?? '').includes('text/html')) { + res.redirect(302, '/app/'); + return; + } + res.status(401).json({ error: 'unauthorized', message: 'Log in again.' }); + }; +} 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>(); 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 { + 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/ and schulcloud://rooms/). 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..0cf29b8 --- /dev/null +++ b/src/mcp/tools/notes.ts @@ -0,0 +1,268 @@ +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, + noteSections, + noteSubjects, + normalizeRelative, + 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 — a note is often a whole school day with a heading per ' + + 'lesson, so the subject filter looks at those headings too. Returns titles and a preview; get_note ' + + 'opens one. search finds notes by their contents as well, and names the lesson it matched in.', + 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, ...noteSubjects(note), 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 }) => { + // A search hit inside a day's note carries `#2`; opening the note + // is the right answer, so the anchor is dropped rather than 404ing on a + // filename nobody has. + const wanted = normalizeRelative(path).replace(/#\d+$/, ''); + try { + return text(renderNote(await readNoteAt(root, wanted))); + } catch (error) { + if (error instanceof NoteNotFound) { + return failure( + `There is no note at "${wanted}". Paths come from list_notes or search and include the folder and ` + + 'the .md ending.', + ); + } + return toToolError(error, `read the note "${wanted}"`); + } + }, + ); + + 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 subjects = noteSubjects(note); + const where = subjects.length > 0 ? ` · ${subjects.join(', ')}` : ''; + const tags = note.tags.length > 0 ? ` · ${note.tags.map((tag) => `#${tag}`).join(' ')}` : ''; + // For a day's note the lessons are the useful preview; for a single piece of + // prose the first line is. + const sections = noteSections(note); + const preview = sections.length > 0 ? `${sections.length} Stunde(n)` : firstLine(note.text); + return [`- **${note.title}** — ${when}${where}${tags} \`${note.path}\``, preview ? ` ${preview}` : undefined] + .filter(Boolean) + .join('\n'); +} + +function renderNote(note: NoteDoc): string { + const subjects = noteSubjects(note); + const facts = [ + note.date ? `Datum: ${germanDay(note.date)}` : undefined, + subjects.length > 0 ? `${subjects.length > 1 ? 'Fächer' : 'Fach'}: ${subjects.join(', ')}` : 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..4a71889 100644 --- a/src/mcp/tools/search.ts +++ b/src/mcp/tools/search.ts @@ -19,6 +19,10 @@ const TOOL_FOR: Record = { // 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,17 @@ 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 — and a lesson inside a day's + // note is reached by opening the note, since `#3` is an index into this + // generation and means nothing to get_note. + if (hit.kind === 'note') { + const notePath = typeof hit.meta?.notePath === 'string' ? hit.meta.notePath : hit.nodeId.replace(/#\d+$/, ''); + return ` → \`get_note\` with path \`${notePath}\``; + } + 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 +191,26 @@ 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; + const heading = typeof hit.meta?.heading === 'string' ? hit.meta.heading : undefined; + // Named as the user's own writing, so it is never quoted as if the school + // had published it. A lesson within a day's note says which lesson. + return ['my own note', subject ?? heading, 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 +221,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 { 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..d3ce8ad 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, noteSections } 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,86 @@ 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 ?? []) { + const common = { + courseId: note.courseId ?? null, + meta: { + notePath: note.path, + ...(note.date ? { date: note.date } : {}), + ...(note.source ? { source: note.source } : {}), + tags: note.tags, + modifiedAt: note.modifiedAt, + }, + }; + + // A note written as one school day, with a heading per lesson, is indexed + // per lesson: one node for "Deutsch, 15.09." rather than one for "Monday". + // Indexed whole, every hit in it would read "my note, Monday" and lose the + // only thing that makes it findable — and "what did we do in Deutsch" + // would match a note whose other five lessons were something else. + const sections = noteSections(note); + if (sections.length > 0) { + for (const [index, section] of sections.entries()) { + nodes.push({ + ...common, + kind: 'note', + // The heading's position, not its text: renaming a heading should + // read as an edit, and two lessons of the same subject on one day + // must not collide. + nodeId: `${note.path}#${index}`, + title: section.subject ? `${section.subject} — ${note.date ?? note.title}` : section.heading, + body: [section.heading, section.text].filter(Boolean).join('\n'), + path: `Notizen/${note.path} → ${section.heading}`, + meta: { ...common.meta, heading: section.heading, ...(section.subject ? { subject: section.subject } : {}) }, + digest: digestOf([section.heading, section.text]), + }); + } + continue; + } + + nodes.push({ + ...common, + kind: 'note', + nodeId: note.path, + title: note.title, + body: noteSearchText(note), + path: `Notizen/${note.path}`, + meta: { ...common.meta, ...(note.subject ? { subject: note.subject } : {}), 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, `` 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('
Erste Zeile
Zweite
Dritte
'), 'Erste Zeile\nZweite\nDritte'); + }); + + it('keeps headings, lists and emphasis', () => { + const markdown = htmlToMarkdown('

Thema

  • eins
  • zwei
'); + 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('
  • eins
  • zwei
danach
'); + assert.equal(markdown, '- eins\n- zwei\n\ndanach'); + }); + + it('renders a checklist as a task list', () => { + assert.match(htmlToMarkdown('
  • erledigt
'), /- \[x\] erledigt/); + }); + + it('keeps a link as a link', () => { + assert.equal(htmlToMarkdown(''), '[Quelle](https://example.org)'); + }); + + it('decodes entities', () => { + assert.equal(htmlToMarkdown('
Erörterung & Analyse
'), '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('
Tafelbild
'), /Anhang aus Apple Notes/); + }); + + it('emits no empty emphasis markers', () => { + assert.equal(htmlToMarkdown('
Text
'), 'Text'); + }); +}); + +describe('convertAppleNote', () => { + const note = { + id: 'x-coredata://1', + name: 'Erörterung', + body: '
Erörterung
These, Argument, Fazit
', + 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":"
a
"}\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/day-note.test.ts b/test/day-note.test.ts new file mode 100644 index 0000000..e3b9990 --- /dev/null +++ b/test/day-note.test.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { dayLessons, dayNoteSkeleton, dayNoteTitle, lessonHeading, missingHeadings } from '../src/core/day-note.ts'; +import { subjectFromHeading } from '../src/core/notes.ts'; +import type { UntisLesson, UntisTimetable } from '../src/core/untis.ts'; + +function lesson(overrides: Partial & { periodId: number }): UntisLesson { + return { + lessonId: 1, + date: '2026-09-18', + start: '08:00', + end: '08:45', + statuses: ['REGULAR'], + cancelled: false, + changed: false, + subjects: [{ name: 'DE', longName: 'Deutsch' }], + teachers: [{ name: 'MEI', longName: 'Meier' }], + rooms: [{ name: '204' }], + classes: [], + replaced: { subjects: [], teachers: [], rooms: [] }, + notes: {}, + homework: [], + online: false, + ...overrides, + }; +} + +function timetable(lessons: UntisLesson[]): UntisTimetable { + return { from: '2026-09-18', to: '2026-09-18', days: [{ date: '2026-09-18', lessons, holidays: [] }] }; +} + +describe('dayNoteTitle', () => { + it('is the weekday and the date, as a person would write it', () => { + assert.equal(dayNoteTitle('2026-09-18'), 'Freitag, 18.09.2026'); + }); +}); + +describe('lessonHeading', () => { + it('leads with the subject, then the time, teacher and room', () => { + assert.equal(lessonHeading(lesson({ periodId: 1 }), 0), '1. Deutsch — 08:00–08:45 · MEI · R 204'); + }); + + it('marks a substitution, because the teacher is not the usual one', () => { + assert.match(lessonHeading(lesson({ periodId: 1, changed: true }), 0), /Vertretung$/); + }); + + it('survives a period with no subject at all', () => { + assert.match(lessonHeading(lesson({ periodId: 1, subjects: [] }), 2), /^3\. Stunde — /); + }); + + it('writes a heading subjectFromHeading reads back — the loop that makes lessons searchable', () => { + // These two have to agree or a day's notes index under nothing: the page + // writes the heading, the indexer reads the subject out of it again. + for (const [index, entry] of [lesson({ periodId: 1 }), lesson({ periodId: 2, changed: true })].entries()) { + assert.equal(subjectFromHeading(lessonHeading(entry, index)), 'Deutsch'); + } + }); +}); + +describe('dayLessons', () => { + it('leaves out a cancelled period, which taught nothing', () => { + const lessons = dayLessons( + timetable([lesson({ periodId: 1 }), lesson({ periodId: 2, cancelled: true, start: '08:50', end: '09:35' })]), + '2026-09-18', + ); + assert.deepEqual(lessons.map((entry) => entry.periodId), [1]); + }); + + it('keeps a substitution, which did happen', () => { + const lessons = dayLessons(timetable([lesson({ periodId: 1, changed: true })]), '2026-09-18'); + assert.equal(lessons[0]?.changed, true); + }); + + it('is empty for a day the timetable does not cover', () => { + assert.deepEqual(dayLessons(timetable([lesson({ periodId: 1 })]), '2026-09-19'), []); + }); +}); + +describe('dayNoteSkeleton', () => { + it('is a heading per lesson with room to write under each', () => { + const text = dayNoteSkeleton(dayLessons(timetable([lesson({ periodId: 1 }), lesson({ periodId: 2, start: '08:50', end: '09:35' })]), '2026-09-18')); + assert.equal(text.match(/^## /gm)?.length, 2); + assert.match(text, /^## 1\. Deutsch/m); + }); + + it('is empty when there are no lessons, rather than a lone heading', () => { + assert.equal(dayNoteSkeleton([]), ''); + }); +}); + +describe('missingHeadings', () => { + const lessons = dayLessons( + timetable([lesson({ periodId: 1 }), lesson({ periodId: 2, start: '08:50', end: '09:35' })]), + '2026-09-18', + ); + + it('is empty when the note already has them', () => { + assert.deepEqual(missingHeadings(dayNoteSkeleton(lessons), lessons), []); + }); + + it('names the lesson a note started early does not have yet', () => { + const started = '## 1. Deutsch — 08:00–08:45 · MEI · R 204\n\nErörterung.\n'; + assert.deepEqual(missingHeadings(started, lessons).map((entry) => entry.periodId), [2]); + }); + + it('recognises a heading the person shortened', () => { + // "2. Deutsch" is still the second period; adding it again would give the + // day two of them. + assert.deepEqual(missingHeadings('## 1. Kurz\n## 2. Auch kurz\n', lessons), []); + }); + + it('is everything for an empty note', () => { + assert.equal(missingHeadings('', lessons).length, 2); + }); +}); diff --git a/test/notes.test.ts b/test/notes.test.ts new file mode 100644 index 0000000..9fb541e --- /dev/null +++ b/test/notes.test.ts @@ -0,0 +1,363 @@ +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 { + dayNotePath, + filterNotes, + NoteConflict, + NoteNotFound, + noteSections, + noteSubjects, + replaceNote, + subjectFromHeading, + 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 { + 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('splitFrontmatter: block lists', () => { + it('reads tags written as indented "- item" lines, which is how editors write them', () => { + // Obsidian and most YAML front ends write a list this way; reading only + // the inline form silently dropped every tag such an editor had written. + const { front } = splitFrontmatter('---\ntitle: T\ntags:\n - klausur\n - aufsatz\n---\nx'); + assert.deepEqual(front.tags, ['klausur', 'aufsatz']); + assert.equal(front.title, 'T'); + }); + + it('stops the list at the next key', () => { + const { front } = splitFrontmatter('---\ntags:\n - eins\nsubject: Deutsch\n---\nx'); + assert.deepEqual(front.tags, ['eins']); + assert.equal(front.subject, 'Deutsch'); + }); +}); + +describe('a note per school day', () => { + const day = parseNote( + '2026/2026-09-18.md', + [ + '## 1. Deutsch — 08:00–08:45 · MEI', + '', + 'Erörterung: These, Argument, Fazit.', + '', + '### Aufbau', + '', + '- Gegenargument nicht vergessen', + '', + '## 2. LF07 — 08:50–09:35 · Sb', + '', + '/24 = 254 nutzbare Adressen', + ].join('\n'), + STAMP, + ); + + it('does not take the year folder for a subject', () => { + // "2026/" is a filing scheme, not a lesson. + assert.equal(day.subject, undefined); + }); + + it('splits into one section per lesson', () => { + assert.deepEqual(noteSections(day).map((section) => section.subject), ['Deutsch', 'LF07']); + }); + + it('keeps subheadings inside their lesson', () => { + const first = noteSections(day)[0]!; + assert.match(first.text, /### Aufbau/); + assert.doesNotMatch(first.text, /LF07/); + }); + + it('reports every subject the day covers', () => { + assert.deepEqual(noteSubjects(day), ['Deutsch', 'LF07']); + }); + + it('is found by a subject filter, which only its headings know', () => { + assert.equal(filterNotes([day], { subject: 'lf07' }).length, 1); + assert.equal(filterNotes([day], { subject: 'Mathe' }).length, 0); + }); + + it('does not split on a ## inside a fenced code block', () => { + const note = parseNote('2026/2026-09-18.md', '## Info\n\n```\n## nicht eine Stunde\n```\n', STAMP); + assert.equal(noteSections(note).length, 1); + }); + + it('has no sections when it is one piece of prose, as an imported note is', () => { + assert.deepEqual(noteSections(parseNote('Deutsch/2026-09-15 A.md', 'Nur Text.', STAMP)), []); + }); +}); + +describe('subjectFromHeading', () => { + it('reads the subject out of every shape the page and a person write', () => { + for (const [heading, expected] of [ + ['1. Deutsch — 08:00–08:45 · MEI · R 204', 'Deutsch'], + ['2) LF07', 'LF07'], + ['Deutsch', 'Deutsch'], + ['08:00 Deutsch', 'Deutsch'], + ['3. Mathe (Vertretung)', 'Mathe'], + ] as const) { + assert.equal(subjectFromHeading(heading), expected, heading); + } + }); + + it('names no subject rather than a wrong one', () => { + for (const heading of ['1.', '08:00–08:45', '—', '###']) { + assert.equal(subjectFromHeading(heading), undefined, heading); + } + }); +}); + +describe('dayNotePath', () => { + it('files a day under its year', () => { + assert.equal(dayNotePath('2026-09-18'), '2026/2026-09-18.md'); + }); +}); + +describe('replaceNote', () => { + it('overwrites, which is what saving from an editor means', async () => { + const dir = await root(); + await replaceNote(dir, dayNotePath('2026-09-18'), { title: 'Freitag', text: 'eins', date: '2026-09-18' }); + const note = await replaceNote(dir, dayNotePath('2026-09-18'), { title: 'Freitag', text: 'zwei', date: '2026-09-18' }); + assert.equal(note.text, 'zwei'); + assert.equal((await readNotes(dir)).length, 1, 'saving twice is one note, not two'); + }); + + it('refuses a save that would clobber a version the editor never saw', async () => { + // The notes folder is synced and open in more than one place; a phone must + // not silently win over a laptop. + const dir = await root(); + const first = await replaceNote(dir, 'x.md', { title: 'X', text: 'vom Laptop' }); + await assert.rejects( + () => replaceNote(dir, 'x.md', { title: 'X', text: 'vom Handy' }, { expectedModifiedAt: '2020-01-01T00:00:00.000Z' }), + NoteConflict, + ); + assert.equal((await readNoteAt(dir, 'x.md')).text, 'vom Laptop', 'the refused save changed nothing'); + assert.ok(first.modifiedAt); + }); + + it('accepts a save carrying the modification time it loaded', async () => { + const dir = await root(); + const loaded = await replaceNote(dir, 'x.md', { title: 'X', text: 'eins' }); + const saved = await replaceNote(dir, 'x.md', { title: 'X', text: 'zwei' }, { expectedModifiedAt: loaded.modifiedAt }); + assert.equal(saved.text, 'zwei'); + }); + + it('creates the note when there is none, with nothing to clash against', async () => { + const dir = await root(); + const note = await replaceNote(dir, dayNotePath('2026-09-18'), { title: 'Freitag', text: 'neu' }, { expectedModifiedAt: '2020-01-01T00:00:00.000Z' }); + assert.equal(note.text, 'neu'); + }); + + it('cannot be steered out of the notes root', async () => { + const dir = await root(); + await assert.rejects(() => replaceNote(dir, '../escape.md', { title: 'X', text: 'x' }), /traversal/); + }); +}); + +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 & { 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, seen?: { periods: number[] }): UntisClient { + return { + async timetable(from: string, to: string): Promise { + 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 { + 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, + ); + }); +}); diff --git a/test/web-auth.test.ts b/test/web-auth.test.ts new file mode 100644 index 0000000..4024e00 --- /dev/null +++ b/test/web-auth.test.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createWebAuth, isSecureRequest, readCookie, SESSION_COOKIE } from '../src/http/web-auth.ts'; + +/** + * The app's login. scrypt is deliberately slow, so these share one authenticator + * rather than building one per test. + */ +const PASSWORD = 'ein-sehr-langes-testpasswort'; +const auth = createWebAuth(PASSWORD); + +function cookieHeader(value: string): string { + return `${SESSION_COOKIE}=${value}`; +} + +describe('createWebAuth without a password', () => { + it('is disabled, and nothing it returns opens anything', () => { + // The app is not served at all in this case; the object exists so callers + // need no branch, and every answer it gives is "no". + const off = createWebAuth(undefined); + assert.equal(off.enabled, false); + assert.equal(off.check(PASSWORD, '::1').ok, false); + assert.equal(off.verify(cookieHeader('anything')), false); + }); +}); + +describe('password check', () => { + it('accepts the password and rejects everything else', () => { + assert.equal(auth.check(PASSWORD, 'a').ok, true); + assert.equal(auth.check(PASSWORD + 'x', 'a').ok, false); + assert.equal(auth.check('', 'a').ok, false); + }); + + it('locks an address out after repeated failures', () => { + const from = 'brute-force'; + let blocked; + for (let attempt = 0; attempt < 12; attempt++) { + blocked = auth.check('wrong', from); + if (blocked.retryAfterSeconds !== undefined) break; + } + assert.ok(blocked?.retryAfterSeconds, 'expected a lockout with a retry hint'); + // And the lockout holds even for the *right* password, or it would be no + // lockout at all — the attacker only has to guess it once. + assert.equal(auth.check(PASSWORD, from).ok, false); + }); + + it('counts per address, so one attacker cannot lock the user out', () => { + assert.equal(auth.check(PASSWORD, 'somebody-else').ok, true); + }); + + it('forgets the failures once a login succeeds', () => { + const from = 'recovers'; + auth.check('wrong', from); + auth.check('wrong', from); + assert.equal(auth.check(PASSWORD, from).ok, true); + assert.equal(auth.check(PASSWORD, from).ok, true); + }); +}); + +describe('session cookies', () => { + it('mints a cookie it accepts back', () => { + assert.equal(auth.verify(cookieHeader(auth.mint())), true); + }); + + it('mints a different value every time', () => { + assert.notEqual(auth.mint(), auth.mint()); + }); + + it('refuses a tampered signature', () => { + const value = auth.mint(); + assert.equal(auth.verify(cookieHeader(`${value.slice(0, -1)}${value.at(-1) === 'A' ? 'B' : 'A'}`)), false); + }); + + it('refuses an extended expiry, which is the point of signing it', () => { + const value = auth.mint(); + const signature = value.slice(value.lastIndexOf('.') + 1); + assert.equal(auth.verify(cookieHeader(`${Date.now() + 10 ** 12}.nonce.${signature}`)), false); + }); + + it('refuses an expired cookie even with a good signature', () => { + // Signed by this key, but for a moment that has passed. + const body = `${Date.now() - 1000}.nonce`; + const fresh = auth.mint(); + const shape = `${body}.${fresh.slice(fresh.lastIndexOf('.') + 1)}`; + assert.equal(auth.verify(cookieHeader(shape)), false); + }); + + it('refuses nonsense and an absent cookie', () => { + for (const value of ['', 'x', 'a.b', '...']) assert.equal(auth.verify(cookieHeader(value)), false, value); + assert.equal(auth.verify(undefined), false); + assert.equal(auth.verify('other=1'), false); + }); + + it('is not accepted by an authenticator built from a different password', () => { + // Changing the password logs everyone out, because the signing key is + // derived from it. + const other = createWebAuth('ein-ganz-anderes-passwort'); + assert.equal(other.verify(cookieHeader(auth.mint())), false); + }); + + it('is HttpOnly and SameSite=Strict, and Secure only over TLS', () => { + const secure = auth.cookie('v', { secure: true }); + assert.match(secure, /HttpOnly/); + assert.match(secure, /SameSite=Strict/); + assert.match(secure, /Secure/); + // Marking it Secure on a plain connection makes it vanish, which looks + // exactly like a broken login. + assert.doesNotMatch(auth.cookie('v', { secure: false }), /Secure/); + }); + + it('clears with an immediate expiry', () => { + assert.match(auth.clearCookie({ secure: true }), /Max-Age=0/); + }); +}); + +describe('readCookie', () => { + it('finds one cookie among several', () => { + assert.equal(readCookie('a=1; sc_app=wanted; b=2', 'sc_app'), 'wanted'); + }); + + it('does not match a name that merely ends the same way', () => { + assert.equal(readCookie('not_sc_app=no', 'sc_app'), undefined); + }); + + it('is undefined for no header', () => { + assert.equal(readCookie(undefined, 'sc_app'), undefined); + }); +}); + +describe('isSecureRequest', () => { + const request = (headers: Record, protocol = 'http') => + ({ get: (name: string) => headers[name.toLowerCase()], protocol }) as never; + + it('trusts the forwarded protocol, which is all there is behind a proxy', () => { + assert.equal(isSecureRequest(request({ 'x-forwarded-proto': 'https' })), true); + assert.equal(isSecureRequest(request({ 'x-forwarded-proto': 'http' })), false); + }); + + it('reads only the first hop of a chain', () => { + assert.equal(isSecureRequest(request({ 'x-forwarded-proto': 'https, http' })), true); + }); + + it('falls back to the connection when nothing forwarded it', () => { + assert.equal(isSecureRequest(request({}, 'https')), true); + assert.equal(isSecureRequest(request({})), false); + }); +});