Merge: the user's own lesson notes, the class register, and an app to write them in
Three sources instead of two. Schulcloud has the material, WebUntis has the schedule and now the class register, and the notes have what the teacher actually stressed — which was the half nothing here could reach. - core/notes.ts, the notes as Markdown files; a note with ## headings is a school day and is indexed per lesson, not whole. - core/untis-history.ts, the class register read backwards — one call per lesson series covers a term, because getLessonTopic2017 answers per series rather than per period. - core/day-note.ts and http/app*, the app at /app: a login, a day editor whose headings come from WebUntis, and a settings page for the Schulcloud token. - scripts/export-apple-notes.js and `schulcloud note import`, the way out of Notes.app, which has no export of its own. docs/NOTES.md is the guide, docs/DEPLOY-NOTES.md the rollout runbook.
This commit is contained in:
38
.env.example
38
.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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
108
CLAUDE.md
108
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 `/<secret>/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.
|
||||
|
||||
18
Dockerfile
18
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
|
||||
|
||||
46
README.md
46
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/<id>`, `schulcloud://rooms/<id>`) holding
|
||||
the same overview `get_course` and `get_room` return. In Claude Code, type `@`
|
||||
and part of the course name.
|
||||
|
||||
**Your own notes are the third source.** Schulcloud has the material and
|
||||
WebUntis has the schedule; neither has what the teacher actually stressed. Point
|
||||
`NOTES_DIR` at a directory of Markdown files and `search`, `what_changed` and
|
||||
all three prompts read it alongside everything else — including a migration path
|
||||
out of Apple Notes. See [docs/NOTES.md](docs/NOTES.md).
|
||||
|
||||
## 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 <id>
|
||||
schulcloud fs tree /courses # browse the file manager ("Dateien")
|
||||
schulcloud fs get "/courses/<course>/<folder>"
|
||||
schulcloud note ls --subject Deutsch
|
||||
pbpaste | schulcloud note add --title Subnetting --subject LF07
|
||||
schulcloud token set # the monthly chore: hand the Pi a fresh Schulcloud token
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
39
docs/AUTH.md
39
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.
|
||||
|
||||
34
docs/CLI.md
34
docs/CLI.md
@@ -39,6 +39,7 @@ schulcloud ls [--course <id>] [--long]
|
||||
schulcloud get <fileId> [--out <path>]
|
||||
schulcloud sync [--dry-run] [--full] [--prune] [--dir <path>] [--jobs <n>]
|
||||
schulcloud refresh [--course <id>] [--force]
|
||||
schulcloud note ... the notes you take in class — see below
|
||||
```
|
||||
|
||||
`ls --long` prints file ids, which is what `get` takes.
|
||||
@@ -114,6 +115,39 @@ while.
|
||||
`Persönliche Dateien/…`, `Team-Dateien/<team>/…` and `Geteilte Dateien/`, once
|
||||
the server's index includes them (`INDEX_FILE_MANAGER`, on by default).
|
||||
|
||||
### Your own lesson notes (`note`)
|
||||
|
||||
The notes you take in class, which Claude reads as context. The full story is in
|
||||
[NOTES.md](NOTES.md); these are the commands.
|
||||
|
||||
```
|
||||
schulcloud note ls [--subject <name>] [--since <date>] [--until <date>] [--long]
|
||||
schulcloud note show <path>
|
||||
schulcloud note add --title <title> [--subject <name>] [--date <date>]
|
||||
[--tags a,b] [--append] text on stdin, or --text
|
||||
schulcloud note import <export.ndjson> [--subject <name>] [--out <dir>] [--dry-run]
|
||||
```
|
||||
|
||||
```console
|
||||
$ pbpaste | schulcloud note add --title "Subnetting" --subject LF07
|
||||
Saved LF07/2026-09-16 Subnetting.md
|
||||
|
||||
$ schulcloud note ls --subject Deutsch --since 2026-09-01
|
||||
2026-09-22 [Deutsch] Sprachanalyse
|
||||
2026-09-15 [Deutsch] Erörterung — Aufbau
|
||||
|
||||
$ schulcloud note show "Deutsch/2026-09-15 Erörterung.md"
|
||||
```
|
||||
|
||||
`note add` reads the note from stdin, so it comes just as easily from a
|
||||
clipboard, an editor or another command; `--append` adds to the note already
|
||||
written for that subject and day rather than starting a second one.
|
||||
|
||||
`note import` takes the file `scripts/export-apple-notes.js` writes on a Mac.
|
||||
`--dry-run` shows what it would do and `--out <dir>` writes the Markdown
|
||||
locally instead of sending it to the server — the only note command that needs
|
||||
no server at all.
|
||||
|
||||
## How sync works
|
||||
|
||||
It is a **one-way mirror, not a two-way sync**, and that follows from the data
|
||||
|
||||
262
docs/DEPLOY-NOTES.md
Normal file
262
docs/DEPLOY-NOTES.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
262
docs/NOTES.md
Normal file
262
docs/NOTES.md
Normal file
@@ -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.
|
||||
42
docs/PI.md
42
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
140
scripts/export-apple-notes.js
Executable file
140
scripts/export-apple-notes.js
Executable file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env osascript -l JavaScript
|
||||
/**
|
||||
* Exports Apple Notes to one JSON object per line, on stdout.
|
||||
*
|
||||
* Run this **on the Mac that has the notes**:
|
||||
*
|
||||
* osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson
|
||||
* osascript -l JavaScript scripts/export-apple-notes.js --folder Deutsch > deutsch.ndjson
|
||||
*
|
||||
* then hand the file to `schulcloud note import notes.ndjson`.
|
||||
*
|
||||
* Why this exists at all: Notes.app has no export. Its database is a Core Data
|
||||
* store whose bodies are compressed protobuf, and the iCloud copy is encrypted,
|
||||
* so scripting the app is not the clumsy route to the notes — it is the only
|
||||
* one. The first run raises a macOS permission dialog ("Terminal wants access
|
||||
* to Notes"); without it every note comes back empty.
|
||||
*
|
||||
* JXA and not AppleScript because it can serialise JSON, and because reading
|
||||
* properties one note at a time is what keeps a locked note from aborting the
|
||||
* run rather than a language preference.
|
||||
*/
|
||||
|
||||
ObjC.import('stdlib');
|
||||
|
||||
function run(argv) {
|
||||
const options = parseArguments(argv);
|
||||
const notes = Application('Notes');
|
||||
notes.includeStandardAdditions = true;
|
||||
|
||||
let items;
|
||||
try {
|
||||
items = notes.notes();
|
||||
} catch (error) {
|
||||
return fail(
|
||||
'Could not read Notes. Grant the terminal access under System Settings → Privacy & Security → ' +
|
||||
'Automation, then run this again.\n' + error,
|
||||
);
|
||||
}
|
||||
|
||||
let written = 0;
|
||||
let skipped = 0;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const note = items[i];
|
||||
const record = readNote(note);
|
||||
if (!record) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (options.folder && (record.folder || '').toLowerCase().indexOf(options.folder.toLowerCase()) === -1) continue;
|
||||
// One object per line, so a huge export streams and a bad note costs one line.
|
||||
console.log(JSON.stringify(record));
|
||||
written++;
|
||||
}
|
||||
|
||||
// stderr, so it never lands in the file being redirected.
|
||||
log('Exported ' + written + ' note(s)' + (skipped > 0 ? ', skipped ' + skipped + ' unreadable' : '') + '.');
|
||||
return '';
|
||||
}
|
||||
|
||||
function readNote(note) {
|
||||
try {
|
||||
// Read the body first: it is the property a locked note refuses, and
|
||||
// there is no point building a record we cannot fill.
|
||||
const body = note.body();
|
||||
return {
|
||||
id: safe(function () { return note.id(); }, ''),
|
||||
name: safe(function () { return note.name(); }, ''),
|
||||
body: body || '',
|
||||
folder: safe(function () { return folderPath(note.container()); }, ''),
|
||||
created: safe(function () { return iso(note.creationDate()); }, ''),
|
||||
modified: safe(function () { return iso(note.modificationDate()); }, ''),
|
||||
};
|
||||
} catch (error) {
|
||||
// A locked note, or one iCloud has not downloaded. Reported, not dropped
|
||||
// silently: a missing lesson is worse than a line in the file.
|
||||
return {
|
||||
id: safe(function () { return note.id(); }, ''),
|
||||
name: safe(function () { return note.name(); }, '(unreadable)'),
|
||||
body: '',
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** "Schule/Deutsch" — the import takes the last segment as the subject. */
|
||||
function folderPath(container) {
|
||||
const parts = [];
|
||||
let current = container;
|
||||
for (let depth = 0; current && depth < 8; depth++) {
|
||||
const name = safe(function () { return current.name(); }, '');
|
||||
if (!name) break;
|
||||
parts.unshift(name);
|
||||
current = safe(function () { return current.container(); }, null);
|
||||
}
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* A Date as local ISO, not UTC.
|
||||
*
|
||||
* `toISOString` would shift a note taken at 08:30 in Erfurt back to the
|
||||
* previous day for anything written before 01:00 or 02:00, and the date is
|
||||
* the whole point of the record.
|
||||
*/
|
||||
function iso(date) {
|
||||
if (!date) return '';
|
||||
const pad = function (value) { return String(value).padStart(2, '0'); };
|
||||
return (
|
||||
date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) +
|
||||
'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds())
|
||||
);
|
||||
}
|
||||
|
||||
function safe(read, fallback) {
|
||||
try {
|
||||
const value = read();
|
||||
return value === undefined || value === null ? fallback : value;
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const options = { folder: '' };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '--folder' && argv[i + 1]) options.folder = argv[++i];
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
$.NSFileHandle.fileHandleWithStandardError.writeData(
|
||||
$.NSString.alloc.initWithUTF8String(message + '\n').dataUsingEncoding($.NSUTF8StringEncoding),
|
||||
);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
log(message);
|
||||
$.exit(1);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { readHidden, readPiped } from '../cli/prompt.ts';
|
||||
import { defaultSyncDir, loadCliConfig, saveCliConfig, configPath } from '../cli/config.ts';
|
||||
import { formatBytes } from '../core/extract.ts';
|
||||
import { fsFind, fsGet, fsList, fsTree } from '../cli/fs.ts';
|
||||
import { noteAdd, noteImport, noteList, noteShow } from '../cli/notes.ts';
|
||||
import { sync, type SyncEvent } from '../cli/sync.ts';
|
||||
|
||||
/**
|
||||
@@ -40,6 +41,17 @@ The file manager ("Dateien") — /my, /courses/<course>, /teams/<team>, /shared:
|
||||
fs get downloads a file, or a folder with everything below it. Names may contain
|
||||
"/" and still resolve; any path segment can also be an id from "fs ls --long".
|
||||
|
||||
Your own lesson notes — Markdown files the agents read as context:
|
||||
|
||||
schulcloud note ls [--subject <name>] [--since <date>] [--until <date>] [--long]
|
||||
schulcloud note show <path>
|
||||
schulcloud note add --title <title> [--subject <name>] [--date <date>]
|
||||
[--tags a,b] [--append] text on stdin, or --text
|
||||
schulcloud note import <export.ndjson> [--subject <name>] [--out <dir>] [--dry-run]
|
||||
|
||||
note import takes the file scripts/export-apple-notes.js writes on a Mac; see
|
||||
docs/NOTES.md. --out writes the Markdown locally instead of sending it.
|
||||
|
||||
--course takes a course or a room id: rooms ("Räume") mirror alongside courses
|
||||
and their files sit under the room's name.
|
||||
|
||||
@@ -74,6 +86,9 @@ async function main(argv: string[]): Promise<number> {
|
||||
return refresh(flags);
|
||||
case 'fs':
|
||||
return fileManager(flags);
|
||||
case 'note':
|
||||
case 'notes':
|
||||
return notes(flags);
|
||||
case 'token':
|
||||
return token(flags);
|
||||
default:
|
||||
@@ -207,6 +222,87 @@ async function fileManager(flags: Flags): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
async function notes(flags: Flags): Promise<number> {
|
||||
const [sub, ...args] = flags._ as string[];
|
||||
const out = (line: string) => process.stdout.write(`${line}\n`);
|
||||
|
||||
// `--out` writes files directly, which is the one note command that needs no
|
||||
// server: a migration should be runnable and inspectable before anything is
|
||||
// sent anywhere.
|
||||
const offlineImport = sub === 'import' && Boolean(flags.out);
|
||||
const api = offlineImport ? undefined : new ApiClient(await loadCliConfig());
|
||||
|
||||
switch (sub) {
|
||||
case 'ls':
|
||||
case 'list':
|
||||
return noteList(
|
||||
api!,
|
||||
{
|
||||
...(flags.subject ? { subject: String(flags.subject) } : {}),
|
||||
...(flags.since ? { since: String(flags.since) } : {}),
|
||||
...(flags.until ? { until: String(flags.until) } : {}),
|
||||
},
|
||||
Boolean(flags.long),
|
||||
out,
|
||||
);
|
||||
case 'show':
|
||||
case 'cat':
|
||||
if (!args[0]) {
|
||||
process.stderr.write('note show needs a path, e.g.: schulcloud note show "Deutsch/2026-09-15 Erörterung.md"\n');
|
||||
return 2;
|
||||
}
|
||||
return noteShow(api!, args[0], out);
|
||||
case 'add': {
|
||||
const title = flags.title ? String(flags.title) : args[0];
|
||||
if (!title) {
|
||||
process.stderr.write('note add needs --title.\n');
|
||||
return 2;
|
||||
}
|
||||
// Piped text is the normal way in: it is how a note gets here from an
|
||||
// editor, a clipboard or another command. Typing it straight in works
|
||||
// too, but only if we say how it ends.
|
||||
if (!flags.text && process.stdin.isTTY) {
|
||||
process.stderr.write('Type the note, then Ctrl-D to save (Ctrl-C to abort):\n');
|
||||
}
|
||||
const body = flags.text ? String(flags.text) : await readPiped();
|
||||
if (!body?.trim()) {
|
||||
process.stderr.write('note add needs the note text: pass --text, or pipe it in.\n');
|
||||
return 2;
|
||||
}
|
||||
return noteAdd(
|
||||
api!,
|
||||
{
|
||||
title,
|
||||
text: body,
|
||||
...(flags.subject ? { subject: String(flags.subject) } : {}),
|
||||
...(flags.date ? { date: String(flags.date) } : {}),
|
||||
...(flags.tags ? { tags: String(flags.tags).split(',').map((tag) => tag.trim()).filter(Boolean) } : {}),
|
||||
append: Boolean(flags.append),
|
||||
},
|
||||
out,
|
||||
);
|
||||
}
|
||||
case 'import':
|
||||
if (!args[0]) {
|
||||
process.stderr.write('note import needs the export file, e.g.: schulcloud note import notes.ndjson\n');
|
||||
return 2;
|
||||
}
|
||||
return noteImport(
|
||||
api,
|
||||
args[0],
|
||||
{
|
||||
...(flags.out ? { outDir: resolve(String(flags.out)) } : {}),
|
||||
...(flags.subject ? { subject: String(flags.subject) } : {}),
|
||||
dryRun: Boolean(flags['dry-run']),
|
||||
},
|
||||
out,
|
||||
);
|
||||
default:
|
||||
process.stderr.write(`Unknown note command "${sub ?? ''}". Use ls, show, add or import.\n\n${USAGE}`);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
async function runSync(flags: Flags): Promise<number> {
|
||||
const config = await loadCliConfig();
|
||||
const root = flags.dir ? resolve(String(flags.dir)) : config.syncDir;
|
||||
|
||||
190
src/cli/apple-notes.ts
Normal file
190
src/cli/apple-notes.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { decodeEntities } from '../core/text.ts';
|
||||
|
||||
/**
|
||||
* Turning an Apple Notes export into notes this server can read.
|
||||
*
|
||||
* Notes.app stores a note's body as HTML and exposes it through AppleScript,
|
||||
* which is the only interface it has — there is no file on disk to copy, no
|
||||
* export format worth the name, and iCloud's copy is encrypted. So
|
||||
* `scripts/export-apple-notes.js` reads the notes through that interface and
|
||||
* writes one JSON object per line; this converts them.
|
||||
*
|
||||
* Kept out of `core/` because nothing on the server needs it: a migration runs
|
||||
* once, from the Mac that has the notes, and the server only ever sees the
|
||||
* Markdown that comes out.
|
||||
*/
|
||||
|
||||
/** One note as `scripts/export-apple-notes.js` writes it. */
|
||||
export interface AppleNote {
|
||||
id: string;
|
||||
name: string;
|
||||
/** The note's HTML body. */
|
||||
body: string;
|
||||
/** The Notes folder it sits in — "Notizen", "Deutsch", "Schule/LF07". */
|
||||
folder?: string;
|
||||
/** ISO timestamps from Notes.app. */
|
||||
created?: string;
|
||||
modified?: string;
|
||||
/** Set when Notes refused the body, e.g. a locked note. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ConvertedNote {
|
||||
title: string;
|
||||
text: string;
|
||||
date?: string;
|
||||
subject?: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An exported note as a note here.
|
||||
*
|
||||
* The creation date becomes the note's date because that is the day of the
|
||||
* lesson it was taken in — the modification date is whenever it was last
|
||||
* tidied, which is not a school day at all.
|
||||
*/
|
||||
export function convertAppleNote(note: AppleNote, options: { subject?: string } = {}): ConvertedNote {
|
||||
const body = htmlToMarkdown(note.body);
|
||||
const title = (note.name || firstLine(body) || 'Notiz').trim();
|
||||
// Notes repeats the title as the first line of the body; keeping both would
|
||||
// give every migrated note a duplicated heading.
|
||||
const text = stripLeadingTitle(body, title);
|
||||
return {
|
||||
title,
|
||||
text,
|
||||
...(dayOf(note.created) ? { date: dayOf(note.created)! } : {}),
|
||||
...(subjectFor(note, options.subject) ? { subject: subjectFor(note, options.subject)! } : {}),
|
||||
source: 'apple-notes',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The subject a note belongs to: what the caller said, else its Notes folder.
|
||||
*
|
||||
* The folder is the only structure Notes has, and someone keeping lesson notes
|
||||
* has almost certainly used it for exactly this. A note loose in the default
|
||||
* folder gets no subject rather than a wrong one.
|
||||
*/
|
||||
function subjectFor(note: AppleNote, override: string | undefined): string | undefined {
|
||||
if (override) return override;
|
||||
const folder = note.folder?.trim();
|
||||
if (!folder) return undefined;
|
||||
// Notes' own default folders say nothing about a subject.
|
||||
if (/^(notes|notizen|alle .*|all .*|recently deleted|zuletzt gelöscht)$/i.test(folder)) return undefined;
|
||||
// A nested folder arrives as "Schule/Deutsch"; the leaf is the subject.
|
||||
return folder.split('/').pop()!.trim() || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apple Notes HTML as Markdown.
|
||||
*
|
||||
* Deliberately small. Notes emits a narrow set of tags — divs, breaks, lists,
|
||||
* headings, bold/italic/underline, links and tables — and the goal is readable
|
||||
* text that keeps its structure, not a faithful rendering. Anything unknown
|
||||
* loses its tag and keeps its words, which is the right failure for a note.
|
||||
*/
|
||||
export function htmlToMarkdown(html: string | undefined | null): string {
|
||||
if (!html) return '';
|
||||
let value = html;
|
||||
|
||||
// Drop what carries no text at all before anything else looks at it.
|
||||
value = value.replace(/<(script|style|head)[^>]*>[\s\S]*?<\/\1>/gi, '');
|
||||
// Attachments (images, scans, drawings) come through as <object>: they have
|
||||
// no text, and silently dropping them would hide that the note had one.
|
||||
value = value.replace(/<object\b[^>]*>[\s\S]*?<\/object>/gi, '\n[Anhang aus Apple Notes — nicht übernommen]\n');
|
||||
value = value.replace(/<img\b[^>]*>/gi, '\n[Bild aus Apple Notes — nicht übernommen]\n');
|
||||
|
||||
value = value.replace(/<br\s*\/?>/gi, '\n');
|
||||
// `li` is deliberately absent: the next `<li>` already opens a line, and
|
||||
// closing one here too would put a blank line between every bullet, which
|
||||
// Markdown renders as a loose list.
|
||||
value = value.replace(/<\/(p|div|tr|h[1-6]|blockquote)>/gi, '\n');
|
||||
|
||||
value = value.replace(/<h([1-6])[^>]*>/gi, (_all, level: string) => `\n${'#'.repeat(Number(level))} `);
|
||||
// A checklist is a list in Notes and a task list in Markdown; the checked
|
||||
// state lives on the li, so it has to be read before the tag is stripped.
|
||||
value = value.replace(/<li\b[^>]*\bchecked\b[^>]*>/gi, '\n- [x] ');
|
||||
value = value.replace(/<li[^>]*>/gi, '\n- ');
|
||||
// A blank line after a list, or whatever follows is absorbed into the last
|
||||
// bullet as a lazy continuation.
|
||||
value = value.replace(/<\/(ul|ol|table)>/gi, '\n\n');
|
||||
value = value.replace(/<(b|strong)>([\s\S]*?)<\/\1>/gi, (_all, _tag, inner: string) => emphasise(inner, '**'));
|
||||
value = value.replace(/<(i|em)>([\s\S]*?)<\/\1>/gi, (_all, _tag, inner: string) => emphasise(inner, '_'));
|
||||
value = value.replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_all, href: string, inner: string) => {
|
||||
const label = stripTags(inner).trim();
|
||||
return label ? `[${label}](${href})` : href;
|
||||
});
|
||||
// Table cells become separators rather than vanishing, or a row of figures
|
||||
// runs into one number.
|
||||
value = value.replace(/<\/(td|th)>/gi, ' | ');
|
||||
|
||||
value = stripTags(value);
|
||||
value = decodeEntities(value);
|
||||
|
||||
return value
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/[ \t ]+/g, ' ').replace(/ \| $/, '').trimEnd())
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Emphasis only around text that has some: `** **` renders as literal stars. */
|
||||
function emphasise(inner: string, marker: string): string {
|
||||
const text = inner.replace(/<br\s*\/?>/gi, '\n');
|
||||
const body = stripTags(text).trim();
|
||||
if (!body) return '';
|
||||
return `${marker}${body}${marker}`;
|
||||
}
|
||||
|
||||
function stripTags(value: string): string {
|
||||
return value.replace(/<[^>]+>/g, '');
|
||||
}
|
||||
|
||||
function firstLine(body: string): string | undefined {
|
||||
return body
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/^#+\s*/, '').trim())
|
||||
.find((line) => line.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the title if the body repeats it.
|
||||
*
|
||||
* Notes shows a note's first line as its name, so `name` and the first line of
|
||||
* `body` are usually the same string.
|
||||
*/
|
||||
function stripLeadingTitle(body: string, title: string): string {
|
||||
const lines = body.split('\n');
|
||||
const firstIndex = lines.findIndex((line) => line.trim().length > 0);
|
||||
if (firstIndex === -1) return '';
|
||||
const first = lines[firstIndex]!.replace(/^#+\s*/, '').replace(/^\*\*(.*)\*\*$/, '$1').trim();
|
||||
if (first !== title.trim()) return body.trim();
|
||||
return lines.slice(firstIndex + 1).join('\n').trim();
|
||||
}
|
||||
|
||||
/** An ISO timestamp as a school day, or nothing when Notes gave none. */
|
||||
function dayOf(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const at = new Date(value);
|
||||
if (Number.isNaN(at.getTime())) return undefined;
|
||||
// The export writes local time, which is the timezone the note was taken in.
|
||||
return value.slice(0, 10).match(/^\d{4}-\d{2}-\d{2}$/) ? value.slice(0, 10) : at.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Parses the export file: one JSON object per line, blank lines ignored. */
|
||||
export function parseExport(contents: string): AppleNote[] {
|
||||
const notes: AppleNote[] = [];
|
||||
for (const [index, line] of contents.split('\n').entries()) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
notes.push(JSON.parse(trimmed) as AppleNote);
|
||||
} catch {
|
||||
// One unparsable line must not cost the export; say which.
|
||||
throw new Error(`Line ${index + 1} of the export is not JSON. Re-run scripts/export-apple-notes.js.`);
|
||||
}
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
@@ -59,6 +59,38 @@ export interface FsWalk {
|
||||
failures?: { path: string; reason: string }[];
|
||||
}
|
||||
|
||||
/** One of the user's own notes, as `/api/notes` reports it. A listing omits `text`. */
|
||||
export interface NoteSummary {
|
||||
path: string;
|
||||
title: string;
|
||||
date?: string;
|
||||
subject?: string;
|
||||
courseId?: string;
|
||||
tags: string[];
|
||||
source?: string;
|
||||
modifiedAt: string;
|
||||
bytes: number;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface NoteListing {
|
||||
root: string;
|
||||
writable: boolean;
|
||||
count: number;
|
||||
notes: NoteSummary[];
|
||||
}
|
||||
|
||||
export interface NoteInputPayload {
|
||||
title: string;
|
||||
text: string;
|
||||
date?: string;
|
||||
subject?: string;
|
||||
courseId?: string;
|
||||
tags?: string[];
|
||||
source?: string;
|
||||
append?: boolean;
|
||||
}
|
||||
|
||||
/** The server's Schulcloud token, as `/api/token` reports it — never the token itself. */
|
||||
export interface TokenInfo {
|
||||
expiresAt?: string;
|
||||
@@ -184,6 +216,28 @@ export class ApiClient {
|
||||
return (await (await this.request(`/api/fs/find?${query}`)).json()) as FsWalk;
|
||||
}
|
||||
|
||||
// --- the user's own notes --------------------------------------------------
|
||||
|
||||
async notes(filter: { subject?: string; since?: string; until?: string; limit?: number } = {}): Promise<NoteListing> {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filter)) if (value !== undefined) query.set(key, String(value));
|
||||
const suffix = query.toString() ? `?${query}` : '';
|
||||
return (await (await this.request(`/api/notes${suffix}`)).json()) as NoteListing;
|
||||
}
|
||||
|
||||
async note(path: string): Promise<NoteSummary> {
|
||||
return (await (await this.request(`/api/notes?${new URLSearchParams({ path })}`)).json()) as NoteSummary;
|
||||
}
|
||||
|
||||
async addNote(input: NoteInputPayload): Promise<NoteSummary & { appended: boolean }> {
|
||||
const response = await this.request('/api/notes', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return (await response.json()) as NoteSummary & { appended: boolean };
|
||||
}
|
||||
|
||||
/** Streams one file-manager file's bytes, by path or by id. */
|
||||
async fsFile(target: { path: string } | { id: string; name: string }): Promise<Response> {
|
||||
const query = 'path' in target ? new URLSearchParams({ path: target.path }) : new URLSearchParams(target);
|
||||
@@ -193,6 +247,8 @@ export class ApiClient {
|
||||
|
||||
function describe(status: number, detail: string, server: string): string {
|
||||
if (status === 401) return `Unauthorized — the token is wrong or expired. Re-run: schulcloud login --server ${server} --token <token>`;
|
||||
// The notes routes have their own 503, and it already says what to do.
|
||||
if (status === 503 && /NOTES_DIR/.test(detail)) return detail;
|
||||
if (status === 503) return 'The server is running without an index, so this command is unavailable. Set DATABASE_URL on the server.';
|
||||
if (status === 409) return detail || 'The sync cursor is unknown to the server. Run a full sync with --full.';
|
||||
if (status === 429) return detail || 'Refreshed too recently — wait a moment, or pass --force.';
|
||||
|
||||
143
src/cli/notes.ts
Normal file
143
src/cli/notes.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { convertAppleNote, parseExport } from './apple-notes.ts';
|
||||
import type { ApiClient, NoteSummary } from './client.ts';
|
||||
import { writeNote } from '../core/notes.ts';
|
||||
|
||||
/**
|
||||
* `schulcloud note` — the user's own lesson notes from the command line.
|
||||
*
|
||||
* The notes live on the server beside the index, so these go through /api like
|
||||
* everything else here. The exception is `import --out`, which writes files
|
||||
* directly: a migration of several hundred notes is worth doing offline, and
|
||||
* the result can be looked at before it goes anywhere.
|
||||
*/
|
||||
|
||||
export interface NoteWriter {
|
||||
(line: string): void;
|
||||
}
|
||||
|
||||
export async function noteList(
|
||||
api: ApiClient,
|
||||
filter: { subject?: string; since?: string; until?: string },
|
||||
long: boolean,
|
||||
out: NoteWriter,
|
||||
): Promise<number> {
|
||||
const listing = await api.notes(filter);
|
||||
if (listing.count === 0) {
|
||||
out(`No notes yet. The server keeps them in ${listing.root}.`);
|
||||
return 0;
|
||||
}
|
||||
for (const note of listing.notes) out(formatLine(note, long));
|
||||
if (listing.notes.length < listing.count) {
|
||||
out(`… ${listing.count - listing.notes.length} more.`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function noteShow(api: ApiClient, path: string, out: NoteWriter): Promise<number> {
|
||||
const note = await api.note(path);
|
||||
out(`# ${note.title}`);
|
||||
const facts = [note.date, note.subject, note.tags.length > 0 ? note.tags.join(', ') : undefined].filter(Boolean);
|
||||
if (facts.length > 0) out(facts.join(' · '));
|
||||
out('');
|
||||
out(note.text ?? '');
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function noteAdd(
|
||||
api: ApiClient,
|
||||
input: { title: string; text: string; subject?: string; date?: string; tags?: string[]; append?: boolean },
|
||||
out: NoteWriter,
|
||||
): Promise<number> {
|
||||
const note = await api.addNote({ ...input, source: 'cli' });
|
||||
out(`${note.appended ? 'Appended to' : 'Saved'} ${note.path}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export interface ImportOptions {
|
||||
/** Write files here instead of sending them to the server. */
|
||||
outDir?: string;
|
||||
/** Force every note into one subject, rather than using its Notes folder. */
|
||||
subject?: string;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates an Apple Notes export.
|
||||
*
|
||||
* Notes with no text are skipped rather than imported empty: an export always
|
||||
* has some — locked notes, and notes that are one attachment — and a store
|
||||
* seeded with blank entries makes every later listing worse.
|
||||
*/
|
||||
export async function noteImport(
|
||||
api: ApiClient | undefined,
|
||||
file: string,
|
||||
options: ImportOptions,
|
||||
out: NoteWriter,
|
||||
): Promise<number> {
|
||||
const contents = await readFile(resolve(file), 'utf8');
|
||||
const exported = parseExport(contents);
|
||||
if (exported.length === 0) {
|
||||
out(`${file} holds no notes. Re-run scripts/export-apple-notes.js on the Mac.`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let empty = 0;
|
||||
let failed = 0;
|
||||
const unreadable: string[] = [];
|
||||
|
||||
for (const note of exported) {
|
||||
if (note.error) {
|
||||
unreadable.push(note.name || note.id);
|
||||
continue;
|
||||
}
|
||||
const converted = convertAppleNote(note, options.subject ? { subject: options.subject } : {});
|
||||
if (!converted.text.trim()) {
|
||||
empty++;
|
||||
continue;
|
||||
}
|
||||
if (options.dryRun) {
|
||||
out(`would import: ${converted.date ?? '????-??-??'} · ${converted.subject ?? '—'} · ${converted.title}`);
|
||||
imported++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (options.outDir) {
|
||||
const { note: written } = await writeNote(options.outDir, converted);
|
||||
out(written.path);
|
||||
} else {
|
||||
if (!api) throw new Error('No server configured and no --out directory given.');
|
||||
const written = await api.addNote(converted);
|
||||
out(written.path);
|
||||
}
|
||||
imported++;
|
||||
} catch (error) {
|
||||
failed++;
|
||||
out(`FAILED ${converted.title}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
out(
|
||||
`${options.dryRun ? 'Would import' : 'Imported'} ${imported} of ${exported.length} note(s)` +
|
||||
(empty > 0 ? `, skipped ${empty} with no text` : '') +
|
||||
(unreadable.length > 0 ? `, ${unreadable.length} unreadable in Notes` : '') +
|
||||
(failed > 0 ? `, FAILED ${failed}` : '') +
|
||||
'.',
|
||||
);
|
||||
if (unreadable.length > 0) {
|
||||
// Almost always locked notes: they are the ones worth naming, because the
|
||||
// fix is to unlock them in Notes and export again.
|
||||
out(`Unreadable (locked, or not downloaded from iCloud): ${unreadable.slice(0, 10).join('; ')}`);
|
||||
}
|
||||
return failed > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
function formatLine(note: NoteSummary, long: boolean): string {
|
||||
const date = note.date ?? ' ';
|
||||
const subject = note.subject ? `[${note.subject}] ` : '';
|
||||
if (!long) return `${date} ${subject}${note.title}`;
|
||||
const tags = note.tags.length > 0 ? ` #${note.tags.join(' #')}` : '';
|
||||
return `${date} ${String(note.bytes).padStart(7)} ${subject}${note.title}${tags}\n ${note.path}`;
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
117
src/core/day-note.ts
Normal file
117
src/core/day-note.ts
Normal file
@@ -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, ' ');
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
654
src/core/notes.ts
Normal file
654
src/core/notes.ts
Normal file
@@ -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);
|
||||
}
|
||||
178
src/core/untis-history.ts
Normal file
178
src/core/untis-history.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { addDays, daysBetween } from './dates.ts';
|
||||
import type { UntisClient, UntisLesson } from './untis.ts';
|
||||
|
||||
/**
|
||||
* The class register, read backwards: what every past lesson actually covered.
|
||||
*
|
||||
* `untis_lesson_topics` answers this one series at a time, from a period id the
|
||||
* caller already has. That is the wrong shape for two of the questions this
|
||||
* exists for — "what have we done in Deutsch this term" and "where does the
|
||||
* material about X come from" — because neither starts from a period id, and
|
||||
* the second needs the text in the search index rather than in a tool call.
|
||||
*
|
||||
* So this walks a date range and merges the two halves the API keeps apart:
|
||||
*
|
||||
* - `getTimetable2017` gives the periods, and with them what a teacher wrote on
|
||||
* one (`text.info` is where this school announces its tests), the homework and
|
||||
* any exam.
|
||||
* - `getLessonTopic2017` gives the `Unterrichtsinhalt` — but only per *series*,
|
||||
* as "the previous topics of this lesson". One call per series therefore
|
||||
* covers all of its past lessons at once, which is why this asks per series
|
||||
* and not per period: a term is a few dozen calls, not a few hundred.
|
||||
*
|
||||
* Requests are sequential on purpose. Every WebUntis call carries its own
|
||||
* one-time code, and the index is built by a background crawl that nobody is
|
||||
* waiting on, so there is nothing to buy by running them in parallel.
|
||||
*/
|
||||
|
||||
/** The longest range one `getTimetable2017` call is asked for. */
|
||||
const CHUNK_DAYS = 90;
|
||||
|
||||
export interface LessonLogEntry {
|
||||
periodId: number;
|
||||
/** The series id: every Tuesday-second-period German lesson shares it. */
|
||||
lessonId: number;
|
||||
date: string;
|
||||
start: string;
|
||||
end: string;
|
||||
subject?: string;
|
||||
subjectLong?: string;
|
||||
teachers: string[];
|
||||
/** The class register's "Unterrichtsinhalt" for this period, when the teacher filled it in. */
|
||||
topic?: string;
|
||||
/** The free-text fields on the period. `info` is where announced tests live. */
|
||||
notes: { lesson?: string; substitution?: string; info?: string };
|
||||
homework: { text: string; due: string }[];
|
||||
exam?: string;
|
||||
}
|
||||
|
||||
export interface LessonLog {
|
||||
from: string;
|
||||
to: string;
|
||||
entries: LessonLogEntry[];
|
||||
/** Series whose topics could not be read, so a gap is visible rather than silent. */
|
||||
failures: { lessonId: number; reason: string }[];
|
||||
/** Periods seen in the range, including the ones that carried nothing. */
|
||||
periodsSeen: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the log for `[from, to]`.
|
||||
*
|
||||
* Only periods that carry something are returned: a lesson with neither a
|
||||
* topic, nor a note, nor homework, nor an exam has nothing to say, and
|
||||
* indexing it would bury the ones that do under a term of empty rows.
|
||||
*/
|
||||
export async function collectLessonLog(
|
||||
untis: UntisClient,
|
||||
options: { from: string; to: string; subject?: string },
|
||||
): Promise<LessonLog> {
|
||||
const lessons: UntisLesson[] = [];
|
||||
for (const [chunkFrom, chunkTo] of chunkRange(options.from, options.to)) {
|
||||
const table = await untis.timetable(chunkFrom, chunkTo);
|
||||
for (const day of table.days) lessons.push(...day.lessons);
|
||||
}
|
||||
|
||||
// A cancelled period taught nothing, and its replacement beside it carries
|
||||
// whatever actually happened.
|
||||
const held = lessons.filter((lesson) => !lesson.cancelled).filter((lesson) => matchesSubject(lesson, options.subject));
|
||||
|
||||
const topics = new Map<number, string>();
|
||||
const failures: { lessonId: number; reason: string }[] = [];
|
||||
for (const [lessonId, periodId] of latestPeriodPerSeries(held)) {
|
||||
try {
|
||||
for (const topic of await untis.lessonTopics(periodId)) topics.set(topic.periodId, topic.text);
|
||||
} catch (error) {
|
||||
// One series the register refuses must not cost the rest of the term.
|
||||
failures.push({ lessonId, reason: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
const entries = held
|
||||
.map((lesson): LessonLogEntry => {
|
||||
const subject = lesson.subjects[0];
|
||||
return {
|
||||
periodId: lesson.periodId,
|
||||
lessonId: lesson.lessonId,
|
||||
date: lesson.date,
|
||||
start: lesson.start,
|
||||
end: lesson.end,
|
||||
...(subject?.name ? { subject: subject.name } : {}),
|
||||
...(subject?.longName ? { subjectLong: subject.longName } : {}),
|
||||
teachers: lesson.teachers.map((teacher) => teacher.longName || teacher.name),
|
||||
...(topics.get(lesson.periodId) ? { topic: topics.get(lesson.periodId) } : {}),
|
||||
notes: lesson.notes,
|
||||
homework: lesson.homework.map((item) => ({ text: item.text, due: item.due })),
|
||||
...(lesson.exam ? { exam: lesson.exam } : {}),
|
||||
};
|
||||
})
|
||||
.filter(hasContent)
|
||||
.sort((a, b) => b.date.localeCompare(a.date) || b.start.localeCompare(a.start));
|
||||
|
||||
return { from: options.from, to: options.to, entries, failures, periodsSeen: held.length };
|
||||
}
|
||||
|
||||
/** True when the entry records anything worth keeping. */
|
||||
export function hasContent(entry: LessonLogEntry): boolean {
|
||||
return Boolean(
|
||||
entry.topic ||
|
||||
entry.notes.lesson ||
|
||||
entry.notes.info ||
|
||||
entry.notes.substitution ||
|
||||
entry.exam ||
|
||||
entry.homework.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything the entry says, as one string — the body the index gets. */
|
||||
export function lessonLogText(entry: LessonLogEntry): string {
|
||||
return [
|
||||
entry.topic,
|
||||
entry.notes.info,
|
||||
entry.notes.lesson,
|
||||
entry.notes.substitution,
|
||||
entry.exam ? `Prüfung: ${entry.exam}` : undefined,
|
||||
...entry.homework.map((item) => `Hausaufgabe bis ${item.due}: ${item.text}`),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** `LF07` matches the subject `LF07` and the long name `Lernfeld 7`. */
|
||||
function matchesSubject(lesson: UntisLesson, subject: string | undefined): boolean {
|
||||
if (!subject) return true;
|
||||
const wanted = subject.trim().toLowerCase();
|
||||
return lesson.subjects.some(
|
||||
(entry) =>
|
||||
entry.name.toLowerCase().includes(wanted) || (entry.longName ?? '').toLowerCase().includes(wanted),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One period id per series — the latest one.
|
||||
*
|
||||
* `getLessonTopic2017` answers with the topics of the lessons *before* the
|
||||
* period it is given, so asking about the last lesson of a series reaches the
|
||||
* whole of its history and asking about the first reaches none of it.
|
||||
*/
|
||||
function latestPeriodPerSeries(lessons: UntisLesson[]): Map<number, number> {
|
||||
const latest = new Map<number, { periodId: number; at: string }>();
|
||||
for (const lesson of lessons) {
|
||||
const at = `${lesson.date} ${lesson.start}`;
|
||||
const current = latest.get(lesson.lessonId);
|
||||
if (!current || at > current.at) latest.set(lesson.lessonId, { periodId: lesson.periodId, at });
|
||||
}
|
||||
return new Map([...latest].map(([lessonId, entry]) => [lessonId, entry.periodId]));
|
||||
}
|
||||
|
||||
/** Splits a range into windows the timetable call will accept. */
|
||||
export function chunkRange(from: string, to: string): [string, string][] {
|
||||
const chunks: [string, string][] = [];
|
||||
let start = from;
|
||||
while (start <= to) {
|
||||
const end = daysBetween(start, to) > CHUNK_DAYS ? addDays(start, CHUNK_DAYS) : to;
|
||||
chunks.push([start, end]);
|
||||
start = addDays(end, 1);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
179
src/http/api.ts
179
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;
|
||||
|
||||
135
src/http/app-page.ts
Normal file
135
src/http/app-page.ts
Normal file
@@ -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 };
|
||||
}
|
||||
188
src/http/app/app.css
Normal file
188
src/http/app/app.css
Normal file
@@ -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; }
|
||||
490
src/http/app/app.js
Normal file
490
src/http/app/app.js
Normal file
@@ -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);
|
||||
})();
|
||||
6
src/http/app/icon.svg
Normal file
6
src/http/app/icon.svg
Normal file
@@ -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>
|
||||
|
After Width: | Height: | Size: 277 B |
87
src/http/app/index.html
Normal file
87
src/http/app/index.html
Normal file
@@ -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</title>
|
||||
<link rel="icon" href="icon.svg" type="image/svg+xml">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>Diese Seite braucht JavaScript.</noscript>
|
||||
|
||||
<!-- Login. Shown until /app/session says otherwise; everything else stays hidden. -->
|
||||
<section id="login" class="screen" hidden>
|
||||
<form id="login-form" class="card">
|
||||
<h1>Anmelden</h1>
|
||||
<label for="password">Passwort</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required autofocus>
|
||||
<button type="submit">Anmelden</button>
|
||||
<p id="login-error" class="error" role="alert" aria-live="assertive"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div id="app" class="screen" hidden>
|
||||
<header>
|
||||
<nav class="tabs">
|
||||
<button type="button" id="tab-notes" class="tab" aria-current="page">Notizen</button>
|
||||
<button type="button" id="tab-settings" class="tab">Einstellungen</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Notes: one school day per note, one heading per lesson. -->
|
||||
<main id="view-notes" class="view">
|
||||
<div class="daybar">
|
||||
<button type="button" id="prev" aria-label="Vorheriger Tag">‹</button>
|
||||
<div class="daybar-centre">
|
||||
<strong id="day-title">…</strong>
|
||||
<input id="day-date" type="date" aria-label="Datum">
|
||||
</div>
|
||||
<button type="button" id="next" aria-label="Nächster Tag">›</button>
|
||||
</div>
|
||||
|
||||
<p id="day-status" class="status" role="status" aria-live="polite"></p>
|
||||
<p id="day-conflict" class="conflict" role="alert" hidden></p>
|
||||
|
||||
<textarea id="editor" spellcheck="true" autocapitalize="sentences"
|
||||
placeholder="Noch nichts für diesen Tag." aria-label="Notizen des Tages"></textarea>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" id="save">Speichern</button>
|
||||
<button type="button" id="fill" hidden>Stunden ergänzen</button>
|
||||
<span id="lessons-hint" class="hint"></span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Settings: the Schulcloud token, and what the server is doing. -->
|
||||
<main id="view-settings" class="view" hidden>
|
||||
<section class="card">
|
||||
<h2>Schulcloud-Token</h2>
|
||||
<p id="token-state" class="status">…</p>
|
||||
<ol class="steps">
|
||||
<li>In einem privaten Fenster bei der Schulcloud anmelden.</li>
|
||||
<li>DevTools → Application → Cookies → Wert des Cookies <code>jwt</code> kopieren.</li>
|
||||
<li>Hier einsetzen und speichern. Der Server prüft ihn erst bei der Schulcloud.</li>
|
||||
<li><strong>Das private Fenster schließen</strong> — offen gelassen meldet es den Token nach etwa zwei Stunden ab.</li>
|
||||
</ol>
|
||||
<form id="token-form">
|
||||
<label for="jwt">Neuer jwt-Cookie</label>
|
||||
<input id="jwt" type="password" autocomplete="off" spellcheck="false">
|
||||
<button type="submit">Token ersetzen</button>
|
||||
</form>
|
||||
<p id="token-result" class="status" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Server</h2>
|
||||
<dl id="server-state"></dl>
|
||||
<button type="button" id="logout">Abmelden</button>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
14
src/http/app/manifest.webmanifest
Normal file
14
src/http/app/manifest.webmanifest
Normal file
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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. ' +
|
||||
|
||||
197
src/http/web-auth.ts
Normal file
197
src/http/web-auth.ts
Normal file
@@ -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<string, { count: number; first: number }>();
|
||||
|
||||
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.' });
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import type { DownloadedFile, SchulcloudClient } from '../core/client.ts';
|
||||
import { crawl, forEachLimited, type Snapshot } from '../core/crawl.ts';
|
||||
import { extractContent, formatBytes } from '../core/extract.ts';
|
||||
import { mirrorPath, resolveWithin } from '../core/paths.ts';
|
||||
import { addDays, schoolToday } from '../core/dates.ts';
|
||||
import { collectLessonLog, type LessonLogEntry } from '../core/untis-history.ts';
|
||||
import type { UntisClient } from '../core/untis.ts';
|
||||
import type { Store } from '../store/store.ts';
|
||||
|
||||
/**
|
||||
@@ -25,6 +28,10 @@ export interface IndexResult {
|
||||
courses: number;
|
||||
/** Rooms walked. Zero is normal — many accounts are in none. */
|
||||
rooms: number;
|
||||
/** The user's own notes picked up from NOTES_DIR. */
|
||||
notes: number;
|
||||
/** Class-register entries read from WebUntis. Zero without a key, or without a register. */
|
||||
lessons: number;
|
||||
files: number;
|
||||
mirrored: number;
|
||||
extracted: number;
|
||||
@@ -48,6 +55,8 @@ export class Indexer {
|
||||
private readonly store: Store;
|
||||
private readonly config: Config;
|
||||
private readonly minIntervalMs: number;
|
||||
/** WebUntis, when configured: the class register is indexed alongside Schulcloud. */
|
||||
private readonly untis: UntisClient | undefined;
|
||||
|
||||
private inFlight = new Map<string, Promise<IndexResult>>();
|
||||
private startedAt: Date | undefined;
|
||||
@@ -56,11 +65,17 @@ export class Indexer {
|
||||
private lastResult: IndexResult | undefined;
|
||||
private lastError: string | undefined;
|
||||
|
||||
constructor(client: SchulcloudClient, store: Store, config: Config, minIntervalMs = 60_000) {
|
||||
constructor(
|
||||
client: SchulcloudClient,
|
||||
store: Store,
|
||||
config: Config,
|
||||
options: { untis?: UntisClient; minIntervalMs?: number } = {},
|
||||
) {
|
||||
this.client = client;
|
||||
this.store = store;
|
||||
this.config = config;
|
||||
this.minIntervalMs = minIntervalMs;
|
||||
this.untis = options.untis;
|
||||
this.minIntervalMs = options.minIntervalMs ?? 60_000;
|
||||
}
|
||||
|
||||
status(): IndexerStatus {
|
||||
@@ -133,8 +148,14 @@ export class Indexer {
|
||||
includePersonalFiles: this.config.indexPersonalFiles,
|
||||
includeFileManager: this.config.indexFileManager,
|
||||
config: this.config,
|
||||
// Notes and the class register belong to the whole account, not to
|
||||
// one course, so a per-course refresh leaves them alone and the
|
||||
// store's carry-forward keeps the previous generation's rows.
|
||||
...(scope === 'full' && this.config.notesDir ? { notesDir: this.config.notesDir } : {}),
|
||||
});
|
||||
|
||||
if (scope === 'full') snapshot.lessonLog = await this.readLessonLog();
|
||||
|
||||
const crawlId = await this.store.saveSnapshot(snapshot, scope);
|
||||
const { mirrored, extracted, skipped } = await this.ingestFiles(snapshot);
|
||||
|
||||
@@ -143,6 +164,8 @@ export class Indexer {
|
||||
scope,
|
||||
courses: snapshot.courses.length,
|
||||
rooms: snapshot.rooms.length,
|
||||
notes: snapshot.notes.length,
|
||||
lessons: snapshot.lessonLog.length,
|
||||
files: snapshot.files.length,
|
||||
mirrored,
|
||||
extracted,
|
||||
@@ -159,6 +182,34 @@ export class Indexer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The WebUntis class register for the configured window.
|
||||
*
|
||||
* Never fatal. WebUntis is a second upstream with its own key, its own
|
||||
* clock requirement and its own outages, and a Schulcloud crawl that failed
|
||||
* because the timetable server was down would be the wrong trade entirely.
|
||||
*/
|
||||
private async readLessonLog(): Promise<LessonLogEntry[]> {
|
||||
const days = this.config.untisHistoryDays;
|
||||
if (!this.untis || days <= 0) return [];
|
||||
const today = schoolToday();
|
||||
try {
|
||||
const log = await collectLessonLog(this.untis, { from: addDays(today, -days), to: today });
|
||||
if (log.failures.length > 0) {
|
||||
console.warn(
|
||||
`[schulcloud-mcp] class register: ${log.failures.length} lesson series could not be read; ` +
|
||||
'their topics are missing from the index.',
|
||||
);
|
||||
}
|
||||
return log.entries;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[schulcloud-mcp] class register not indexed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads, mirrors and extracts every file the index has no text for.
|
||||
*
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -11,6 +11,7 @@ import { registerH5pTools } from './tools/h5p.ts';
|
||||
import { registerOverviewTools } from './tools/overview.ts';
|
||||
import { registerRawTool } from './tools/raw.ts';
|
||||
import { registerIndexTools } from './tools/index-tools.ts';
|
||||
import { registerNoteTools } from './tools/notes.ts';
|
||||
import { registerSearchTool } from './tools/search.ts';
|
||||
import { registerRoomTools } from './tools/rooms.ts';
|
||||
import { registerSubmissionTools } from './tools/submissions.ts';
|
||||
@@ -48,13 +49,22 @@ How the content is organised, and the usual path through it:
|
||||
graded submission, say it was not found rather than that none was given. On a teacher account these
|
||||
tools report other people's submissions too.
|
||||
|
||||
**The user's own notes are a third source, and often the best one.** When the note tools are listed, the user
|
||||
keeps notes from their lessons as Markdown files: list_notes and get_note read them, search finds them by
|
||||
content, and add_note writes one. They record what a teacher said and stressed, which no upload does — so
|
||||
consult them whenever the question is what was covered in class, what a topic means "the way we did it", or
|
||||
what to revise for a test, and say when a note disagrees with the material. Notes are the user's own words:
|
||||
quote them, do not silently correct them.
|
||||
|
||||
**The timetable is not in Schulcloud.** When the untis_* tools are listed, the school's schedule lives in
|
||||
WebUntis and they are the only way to it: untis_timetable says which lessons a day actually holds, what was
|
||||
cancelled ("Entfall"), what is a substitution ("Vertretung") and what a teacher noted on a period — announced
|
||||
tests are usually in those notes. Schulcloud holds the material for those lessons, so the two go together:
|
||||
take the subject from untis_timetable, then find its course with list_courses. untis_homework is the class
|
||||
register's homework, which is a different list from Schulcloud's tasks; check both. untis_lesson_topics says
|
||||
what previous lessons of a subject actually covered.
|
||||
what previous lessons of a subject actually covered — pass it a subject to read back over a whole term, which
|
||||
is the fastest way to reconstruct what a course has done. Those class-register entries are in the index too,
|
||||
so search finds them beside the Schulcloud material.
|
||||
|
||||
When the user names a topic rather than a course, use search — the API has no search endpoint, so it walks the
|
||||
courses and matches client-side, which takes a few seconds but covers board text and file names.
|
||||
@@ -62,7 +72,9 @@ courses and matches client-side, which takes a few seconds but covers board text
|
||||
The user can also attach a course or room directly (resources schulcloud://courses/<id> and schulcloud://rooms/<id>).
|
||||
An attached one is exactly what get_course or get_room returns, so do not fetch it again — continue from its ids.
|
||||
|
||||
Everything here is read-only; nothing in this server can modify the account.`;
|
||||
Everything that touches Schulcloud and WebUntis is read-only: no tool here can change the school account,
|
||||
hand anything in, or mark anything done. The one exception writes nowhere near them — add_note, when it is
|
||||
listed, saves a file in the user's own notes directory.`;
|
||||
|
||||
export function createServer(config: Config, services?: Services): { server: McpServer; context: ServerContext } {
|
||||
const context = new ServerContext(config, services);
|
||||
@@ -82,6 +94,8 @@ export function createServer(config: Config, services?: Services): { server: Mcp
|
||||
registerIndexTools(server, context);
|
||||
// Only when a key is configured: the tools are not offered at all otherwise.
|
||||
registerUntisTools(server, context);
|
||||
// Same rule, for NOTES_DIR.
|
||||
registerNoteTools(server, context);
|
||||
registerRawTool(server, context);
|
||||
registerResources(server, context);
|
||||
registerPrompts(server, context);
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
268
src/mcp/tools/notes.ts
Normal file
268
src/mcp/tools/notes.ts
Normal file
@@ -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 `<path>#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.',
|
||||
]);
|
||||
}
|
||||
@@ -19,6 +19,10 @@ const TOOL_FOR: Record<string, string> = {
|
||||
// A submission is reached through its task, not by an id of its own: there
|
||||
// is no get_submission because the API has no route to one.
|
||||
submission: 'get_task',
|
||||
note: 'get_note',
|
||||
// A class-register hit is followed up by its series, not by the single
|
||||
// period: untis_lesson_topics with that periodId returns the lessons around it.
|
||||
untis: 'untis_lesson_topics',
|
||||
};
|
||||
|
||||
export function registerSearchTool(server: McpServer, context: ServerContext): void {
|
||||
@@ -31,14 +35,20 @@ export function registerSearchTool(server: McpServer, context: ServerContext): v
|
||||
'names — and, unlike anything else here, **the text inside PDFs, Word, PowerPoint and OpenDocument ' +
|
||||
'files**. Use it whenever the user names a topic rather than a course ("where is the stuff about ' +
|
||||
'encryption?"). Matching is case- and accent-insensitive and understands German word forms. ' +
|
||||
'It covers three sources at once: the Schulcloud material, **the user\'s own lesson notes** and ' +
|
||||
'**the WebUntis class register** — so one query answers "what do we have on this, what did I write ' +
|
||||
'down, and when did we do it". Restrict with kinds to just one of them. ' +
|
||||
'Results come from a local index; if they look stale, refresh_index re-reads Schulcloud.',
|
||||
inputSchema: {
|
||||
query: z.string().min(2).describe('What to look for. German and English both work.'),
|
||||
courseId: z.string().optional().describe('Restrict the search to a single course.'),
|
||||
kinds: z
|
||||
.array(z.enum(['course', 'room', 'board', 'lesson', 'task', 'file']))
|
||||
.array(z.enum(['course', 'room', 'board', 'lesson', 'task', 'file', 'submission', 'note', 'untis']))
|
||||
.optional()
|
||||
.describe('Restrict to certain kinds of thing, e.g. ["file"] to find documents only.'),
|
||||
.describe(
|
||||
'Restrict to certain kinds of thing: ["file"] for documents only, ["note"] for the user\'s own ' +
|
||||
'notes, ["untis"] for what the class register says was taught.',
|
||||
),
|
||||
limit: z.number().int().min(1).max(100).default(30).describe('Maximum number of hits to return.'),
|
||||
fresh: z
|
||||
.boolean()
|
||||
@@ -117,6 +127,9 @@ async function liveSearch(
|
||||
// Same trade as files: worth two extra requests per pad when the caller
|
||||
// named a course, too slow to do across every course they can see.
|
||||
config: scoped ? context.config : undefined,
|
||||
// Notes are local files, so the live path can afford them and must: a
|
||||
// fresh search that silently dropped them would disagree with the index.
|
||||
...(context.config.notesDir ? { notesDir: context.config.notesDir } : {}),
|
||||
});
|
||||
const hits = searchSnapshot(snapshot, query, limit);
|
||||
|
||||
@@ -153,6 +166,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,8 +50,10 @@ export async function createServices(config: Config): Promise<Services> {
|
||||
|
||||
const files = new FileManager(client);
|
||||
const store = await Store.open(config.databaseUrl);
|
||||
const indexer = store ? new Indexer(client, store, config) : undefined;
|
||||
const untis = config.untis ? new UntisClient(config.untis, config.requestTimeoutMs) : undefined;
|
||||
// The indexer gets the same client, so the class register is read with the
|
||||
// master data the untis_* tools have already paid for.
|
||||
const indexer = store ? new Indexer(client, store, config, { untis }) : undefined;
|
||||
|
||||
if (!store) {
|
||||
console.warn(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
100
test/apple-notes.test.ts
Normal file
100
test/apple-notes.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { convertAppleNote, htmlToMarkdown, parseExport } from '../src/cli/apple-notes.ts';
|
||||
|
||||
/**
|
||||
* The migration path from Notes.app. The HTML here is the shape Notes actually
|
||||
* emits — divs for lines, a repeated title, `<object>` for attachments — since
|
||||
* the converter's whole job is to survive that particular markup.
|
||||
*/
|
||||
|
||||
describe('htmlToMarkdown', () => {
|
||||
it('turns divs and breaks into lines', () => {
|
||||
assert.equal(htmlToMarkdown('<div>Erste Zeile</div><div>Zweite<br>Dritte</div>'), 'Erste Zeile\nZweite\nDritte');
|
||||
});
|
||||
|
||||
it('keeps headings, lists and emphasis', () => {
|
||||
const markdown = htmlToMarkdown('<h1>Thema</h1><ul><li>eins</li><li><b>zwei</b></li></ul>');
|
||||
assert.match(markdown, /^# Thema$/m);
|
||||
assert.match(markdown, /^- eins$/m);
|
||||
assert.match(markdown, /^- \*\*zwei\*\*$/m);
|
||||
});
|
||||
|
||||
it('keeps bullets together and separates what follows the list', () => {
|
||||
// Blank lines between bullets make a loose list; no blank line after one
|
||||
// makes the next paragraph a lazy continuation of the last bullet.
|
||||
const markdown = htmlToMarkdown('<ul><li>eins</li><li>zwei</li></ul><div>danach</div>');
|
||||
assert.equal(markdown, '- eins\n- zwei\n\ndanach');
|
||||
});
|
||||
|
||||
it('renders a checklist as a task list', () => {
|
||||
assert.match(htmlToMarkdown('<ul><li checked="checked">erledigt</li></ul>'), /- \[x\] erledigt/);
|
||||
});
|
||||
|
||||
it('keeps a link as a link', () => {
|
||||
assert.equal(htmlToMarkdown('<div><a href="https://example.org">Quelle</a></div>'), '[Quelle](https://example.org)');
|
||||
});
|
||||
|
||||
it('decodes entities', () => {
|
||||
assert.equal(htmlToMarkdown('<div>Erörterung & Analyse</div>'), 'Erörterung & Analyse');
|
||||
});
|
||||
|
||||
it('says an attachment was there rather than dropping it silently', () => {
|
||||
// A note that was one scan would otherwise import as empty, and nobody
|
||||
// would know the picture had been left behind.
|
||||
assert.match(htmlToMarkdown('<div>Tafelbild</div><object data="x"></object>'), /Anhang aus Apple Notes/);
|
||||
});
|
||||
|
||||
it('emits no empty emphasis markers', () => {
|
||||
assert.equal(htmlToMarkdown('<div><b> </b>Text</div>'), 'Text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertAppleNote', () => {
|
||||
const note = {
|
||||
id: 'x-coredata://1',
|
||||
name: 'Erörterung',
|
||||
body: '<div><b>Erörterung</b></div><div>These, Argument, Fazit</div>',
|
||||
folder: 'Schule/Deutsch',
|
||||
created: '2026-09-15T08:30:00',
|
||||
modified: '2026-09-20T19:00:00',
|
||||
};
|
||||
|
||||
it('dates the note when it was written, not when it was last touched', () => {
|
||||
// The creation date is the lesson; the modification date is whenever it
|
||||
// was last tidied, which is not a school day at all.
|
||||
assert.equal(convertAppleNote(note).date, '2026-09-15');
|
||||
});
|
||||
|
||||
it('takes the leaf of the Notes folder as the subject', () => {
|
||||
assert.equal(convertAppleNote(note).subject, 'Deutsch');
|
||||
});
|
||||
|
||||
it('ignores Notes\' own default folders', () => {
|
||||
assert.equal(convertAppleNote({ ...note, folder: 'Notizen' }).subject, undefined);
|
||||
});
|
||||
|
||||
it('lets an explicit subject win', () => {
|
||||
assert.equal(convertAppleNote(note, { subject: 'LF07' }).subject, 'LF07');
|
||||
});
|
||||
|
||||
it('does not repeat the title as the first line of the body', () => {
|
||||
const converted = convertAppleNote(note);
|
||||
assert.equal(converted.title, 'Erörterung');
|
||||
assert.equal(converted.text, 'These, Argument, Fazit');
|
||||
});
|
||||
|
||||
it('marks where it came from', () => {
|
||||
assert.equal(convertAppleNote(note).source, 'apple-notes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExport', () => {
|
||||
it('reads one note per line and ignores blank lines', () => {
|
||||
assert.equal(parseExport('{"id":"1","name":"A","body":"<div>a</div>"}\n\n{"id":"2","name":"B","body":""}\n').length, 2);
|
||||
});
|
||||
|
||||
it('names the line it could not read', () => {
|
||||
assert.throws(() => parseExport('{"id":"1"}\nnope\n'), /Line 2/);
|
||||
});
|
||||
});
|
||||
115
test/day-note.test.ts
Normal file
115
test/day-note.test.ts
Normal file
@@ -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<UntisLesson> & { 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);
|
||||
});
|
||||
});
|
||||
363
test/notes.test.ts
Normal file
363
test/notes.test.ts
Normal file
@@ -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<string> {
|
||||
return mkdtemp(join(tmpdir(), 'schulcloud-notes-'));
|
||||
}
|
||||
|
||||
describe('splitFrontmatter', () => {
|
||||
it('reads a leading block and keeps the body', () => {
|
||||
const { front, body } = splitFrontmatter('---\ntitle: Erörterung\ndate: 2026-09-15\n---\n\nText hier.\n');
|
||||
assert.equal(front.title, 'Erörterung');
|
||||
assert.equal(front.date, '2026-09-15');
|
||||
assert.equal(body.trim(), 'Text hier.');
|
||||
});
|
||||
|
||||
it('leaves a note that merely starts with a rule alone', () => {
|
||||
// A horizontal rule with no closing fence must not eat the note.
|
||||
const { front, body } = splitFrontmatter('---\nkein Frontmatter, nur ein Strich\n');
|
||||
assert.deepEqual(front, {});
|
||||
assert.match(body, /kein Frontmatter/);
|
||||
});
|
||||
|
||||
it('accepts a note with no frontmatter at all', () => {
|
||||
const { front, body } = splitFrontmatter('# Titel\n\nText.');
|
||||
assert.deepEqual(front, {});
|
||||
assert.equal(body, '# Titel\n\nText.');
|
||||
});
|
||||
|
||||
it('takes a German date and an inline tag list', () => {
|
||||
const { front } = splitFrontmatter('---\ndate: 15.09.2026\ntags: [klausur, "aufsatz"]\nfach: Deutsch\n---\nx');
|
||||
assert.equal(front.date, '2026-09-15');
|
||||
assert.deepEqual(front.tags, ['klausur', 'aufsatz']);
|
||||
// "fach" is the German spelling of subject and has to mean the same thing.
|
||||
assert.equal(front.subject, 'Deutsch');
|
||||
});
|
||||
|
||||
it('keeps unknown keys rather than dropping them', () => {
|
||||
const { front } = splitFrontmatter('---\ntitle: T\nlehrer: Frau Meier\n---\nx');
|
||||
assert.equal(front.extra?.lehrer, 'Frau Meier');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseNote', () => {
|
||||
it('falls back to the heading, then to the filename, for a title', () => {
|
||||
assert.equal(parseNote('a.md', '# Kryptografie\n\nText', STAMP).title, 'Kryptografie');
|
||||
assert.equal(parseNote('Deutsch/2026-09-15 Erörterung.md', 'nur Text', STAMP).title, 'Erörterung');
|
||||
});
|
||||
|
||||
it('takes the date from the filename when the frontmatter has none', () => {
|
||||
assert.equal(parseNote('Deutsch/2026-09-15 Erörterung.md', 'x', STAMP).date, '2026-09-15');
|
||||
});
|
||||
|
||||
it('never dates a note from its mtime', () => {
|
||||
// An import writes every file today; dating a year of lessons "today"
|
||||
// would make the whole store useless for revision.
|
||||
assert.equal(parseNote('lose Notiz.md', 'x', STAMP).date, undefined);
|
||||
});
|
||||
|
||||
it('takes the folder as the subject', () => {
|
||||
assert.equal(parseNote('LF07/2026-09-15 Netze.md', 'x', STAMP).subject, 'LF07');
|
||||
assert.equal(parseNote('lose.md', 'x', STAMP).subject, undefined);
|
||||
});
|
||||
|
||||
it('round-trips through renderNote', () => {
|
||||
const rendered = renderNote({ title: 'Erörterung', date: '2026-09-15', subject: 'Deutsch', tags: ['klausur'] }, 'Body');
|
||||
const note = parseNote('Deutsch/x.md', rendered, STAMP);
|
||||
assert.equal(note.title, 'Erörterung');
|
||||
assert.equal(note.date, '2026-09-15');
|
||||
assert.equal(note.subject, 'Deutsch');
|
||||
assert.deepEqual(note.tags, ['klausur']);
|
||||
assert.equal(note.text, 'Body');
|
||||
});
|
||||
});
|
||||
|
||||
describe('notePathFor', () => {
|
||||
it('is subject then date then title', () => {
|
||||
assert.equal(notePathFor({ date: '2026-09-15', subject: 'Deutsch', title: 'Erörterung' }), 'Deutsch/2026-09-15 Erörterung.md');
|
||||
});
|
||||
|
||||
it('reduces a hostile title to one component', () => {
|
||||
// The title comes from a tool call, so it is untrusted input that becomes
|
||||
// a filename — the same boundary the file mirror has.
|
||||
const path = notePathFor({ date: '2026-09-15', subject: '../../etc', title: '../../.ssh/authorized_keys' });
|
||||
assert.equal(path.split('/').length, 2);
|
||||
assert.ok(!path.includes('..'), path);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readNotes', () => {
|
||||
it('is empty, not an error, for a directory that does not exist', async () => {
|
||||
assert.deepEqual(await readNotes(join(tmpdir(), 'schulcloud-notes-absent-xyz')), []);
|
||||
});
|
||||
|
||||
it('walks folders, skips dotfiles and non-notes, and sorts newest first', async () => {
|
||||
const dir = await root();
|
||||
await mkdir(join(dir, 'Deutsch'), { recursive: true });
|
||||
await mkdir(join(dir, '.obsidian'), { recursive: true });
|
||||
await writeFile(join(dir, 'Deutsch', '2026-09-15 Erörterung.md'), 'A');
|
||||
await writeFile(join(dir, 'Deutsch', '2026-09-22 Analyse.md'), 'B');
|
||||
await writeFile(join(dir, '.obsidian', 'workspace.md'), 'nope');
|
||||
await writeFile(join(dir, 'bild.png'), 'nope');
|
||||
|
||||
const notes = await readNotes(dir);
|
||||
assert.deepEqual(notes.map((note) => note.title), ['Analyse', 'Erörterung']);
|
||||
});
|
||||
|
||||
it('refuses to read its way out of the root', async () => {
|
||||
const dir = await root();
|
||||
await assert.rejects(() => readNoteAt(dir, '../../etc/passwd'), /traversal/);
|
||||
});
|
||||
|
||||
it('reports a missing note as missing', async () => {
|
||||
const dir = await root();
|
||||
await assert.rejects(() => readNoteAt(dir, 'Deutsch/nichts.md'), NoteNotFound);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeNote', () => {
|
||||
it('creates a note with frontmatter at the derived path', async () => {
|
||||
const dir = await root();
|
||||
const { note } = await writeNote(dir, { title: 'Erörterung', text: 'Aufbau: These, Argument, Fazit.', subject: 'Deutsch', date: '2026-09-15' });
|
||||
assert.equal(note.path, 'Deutsch/2026-09-15 Erörterung.md');
|
||||
assert.equal(note.subject, 'Deutsch');
|
||||
assert.match(await readFile(join(dir, note.path), 'utf8'), /^---\ntitle: Erörterung\n/);
|
||||
});
|
||||
|
||||
it('appends to the same file when asked, so a lesson stays one note', async () => {
|
||||
const dir = await root();
|
||||
await writeNote(dir, { title: 'Erörterung', text: 'Erstens.', subject: 'Deutsch', date: '2026-09-15' });
|
||||
const { note, appended } = await writeNote(dir, {
|
||||
title: 'Nachtrag', text: 'Zweitens.', subject: 'Deutsch', date: '2026-09-15',
|
||||
path: 'Deutsch/2026-09-15 Erörterung.md', append: true,
|
||||
});
|
||||
assert.equal(appended, true);
|
||||
assert.match(note.text, /Erstens\./);
|
||||
assert.match(note.text, /Zweitens\./);
|
||||
assert.equal((await readNotes(dir)).length, 1);
|
||||
});
|
||||
|
||||
it('appends by lesson, not by title — a second note in the same lesson has another name', async () => {
|
||||
// "halt das auch noch fest" mid-lesson carries a new title; deriving the
|
||||
// path from it would start a second note every time, which is the one
|
||||
// thing append exists to prevent.
|
||||
const dir = await root();
|
||||
await writeNote(dir, { title: 'Erörterung', text: 'Erstens.', subject: 'Deutsch', date: '2026-09-15' });
|
||||
const { note, appended } = await writeNote(dir, {
|
||||
title: 'Nachtrag', text: 'Zweitens.', subject: 'Deutsch', date: '2026-09-15', append: true,
|
||||
});
|
||||
assert.equal(appended, true);
|
||||
assert.equal(note.path, 'Deutsch/2026-09-15 Erörterung.md');
|
||||
assert.equal((await readNotes(dir)).length, 1);
|
||||
});
|
||||
|
||||
it('creates the note when append finds nothing to append to', async () => {
|
||||
const dir = await root();
|
||||
const { note, appended } = await writeNote(dir, { title: 'Erstes', text: 'x', subject: 'Deutsch', date: '2026-09-15', append: true });
|
||||
assert.equal(appended, false);
|
||||
assert.equal(note.path, 'Deutsch/2026-09-15 Erstes.md');
|
||||
});
|
||||
|
||||
it('does not append across days or subjects', async () => {
|
||||
const dir = await root();
|
||||
await writeNote(dir, { title: 'Montag', text: 'a', subject: 'Deutsch', date: '2026-09-15' });
|
||||
const otherDay = await writeNote(dir, { title: 'Dienstag', text: 'b', subject: 'Deutsch', date: '2026-09-16', append: true });
|
||||
const otherSubject = await writeNote(dir, { title: 'Netze', text: 'c', subject: 'LF07', date: '2026-09-15', append: true });
|
||||
assert.equal(otherDay.appended, false);
|
||||
assert.equal(otherSubject.appended, false);
|
||||
assert.equal((await readNotes(dir)).length, 3);
|
||||
});
|
||||
|
||||
it('never overwrites: a second note of the same name gets its own file', async () => {
|
||||
const dir = await root();
|
||||
await writeNote(dir, { title: 'Test', text: 'eins', subject: 'Deutsch', date: '2026-09-15' });
|
||||
const { note, appended } = await writeNote(dir, { title: 'Test', text: 'zwei', subject: 'Deutsch', date: '2026-09-15' });
|
||||
assert.equal(appended, false);
|
||||
assert.equal(note.path, 'Deutsch/2026-09-15 Test 2.md');
|
||||
assert.equal((await readNotes(dir)).length, 2);
|
||||
});
|
||||
|
||||
it('cannot be steered out of the notes root by its title', async () => {
|
||||
const dir = await root();
|
||||
const { note } = await writeNote(dir, { title: '../../escape', text: 'x', subject: '..', date: '2026-09-15' });
|
||||
assert.ok(!note.path.includes('..'), note.path);
|
||||
assert.equal((await readNotes(dir)).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('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']);
|
||||
});
|
||||
});
|
||||
@@ -29,11 +29,24 @@ function assertDisposable(url: string): void {
|
||||
function snapshot(
|
||||
courses: { id: string; title: string; boardText?: string; files?: { id: string; name: string; size: number }[] }[],
|
||||
rooms: { id: string; name: string; boardText?: string }[] = [],
|
||||
notes: { path: string; title: string; text: string; subject?: string; date?: string }[] = [],
|
||||
): Snapshot {
|
||||
return {
|
||||
crawledAt: new Date(),
|
||||
schoolId: 'school1',
|
||||
failures: [],
|
||||
submissions: [],
|
||||
lessonLog: [],
|
||||
notes: notes.map((n) => ({
|
||||
path: n.path,
|
||||
title: n.title,
|
||||
text: n.text,
|
||||
...(n.subject ? { subject: n.subject } : {}),
|
||||
...(n.date ? { date: n.date } : {}),
|
||||
tags: [],
|
||||
modifiedAt: '2026-09-15T10:00:00.000Z',
|
||||
bytes: n.text.length,
|
||||
})),
|
||||
rooms: rooms.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
@@ -152,6 +165,45 @@ describe('Store', { skip: DB_URL ? false : 'set TEST_DATABASE_URL to run' }, ()
|
||||
assert.ok(diff.changed.some((n) => n.nodeId === 'r1' && n.kind === 'room'), 'a renamed room is reported as changed');
|
||||
});
|
||||
|
||||
it('indexes the user\'s own notes beside the course material', async () => {
|
||||
// The point of the notes store: one search covers what the school
|
||||
// uploaded and what the user wrote down in the lesson.
|
||||
const before = await store.saveSnapshot(
|
||||
snapshot(
|
||||
[{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }],
|
||||
[],
|
||||
[{ path: 'Deutsch/2026-09-15 Erörterung.md', title: 'Erörterung', subject: 'Deutsch', date: '2026-09-15', text: 'These, Argument, Fazit. Frau Meier betont den Schluss.' }],
|
||||
),
|
||||
'full',
|
||||
);
|
||||
const hits = await store.search('Erörterung', { limit: 5 });
|
||||
const note = hits.find((hit) => hit.kind === 'note');
|
||||
assert.ok(note, 'a note is searchable');
|
||||
assert.equal(note.nodeId, 'Deutsch/2026-09-15 Erörterung.md', 'the path is the id get_note takes');
|
||||
assert.equal(note.meta?.subject, 'Deutsch');
|
||||
|
||||
// And an edited note is a change, so what_changed reports it.
|
||||
const after = await store.saveSnapshot(
|
||||
snapshot(
|
||||
[{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }],
|
||||
[],
|
||||
[{ path: 'Deutsch/2026-09-15 Erörterung.md', title: 'Erörterung', subject: 'Deutsch', date: '2026-09-15', text: 'These, Argument, Fazit. Gegenargument nicht vergessen.' }],
|
||||
),
|
||||
'full',
|
||||
);
|
||||
const diff = await store.diff(before, after);
|
||||
assert.ok(diff.changed.some((n) => n.kind === 'note'), 'an edited note is reported as changed');
|
||||
});
|
||||
|
||||
it('keeps notes through a per-course crawl, which never looks at them', async () => {
|
||||
// Notes belong to the account, not to a course, so a per-course refresh
|
||||
// must carry them forward rather than appear to delete them.
|
||||
const before = await store.latestCrawlId();
|
||||
const after = await store.saveSnapshot(snapshot([{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }]), 'c1');
|
||||
const diff = await store.diff(before!, after);
|
||||
assert.ok(!diff.removed.some((n) => n.kind === 'note'), 'a per-course crawl must not delete the notes');
|
||||
});
|
||||
|
||||
it('carries other courses forward on a per-course crawl', async () => {
|
||||
await store.saveSnapshot(
|
||||
snapshot([
|
||||
|
||||
160
test/untis-history.test.ts
Normal file
160
test/untis-history.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { chunkRange, collectLessonLog, hasContent, lessonLogText } from '../src/core/untis-history.ts';
|
||||
import type { UntisClient, UntisLesson, UntisTimetable, UntisTopic } from '../src/core/untis.ts';
|
||||
|
||||
/**
|
||||
* The class register, read backwards. The client is a stand-in: what is under
|
||||
* test is which periods are asked about and how the two halves are merged, not
|
||||
* the JSON-RPC layer, which test/untis.test.ts already covers.
|
||||
*/
|
||||
|
||||
function lesson(overrides: Partial<UntisLesson> & { periodId: number; lessonId: number; date: string }): UntisLesson {
|
||||
return {
|
||||
start: '08:00',
|
||||
end: '08:45',
|
||||
statuses: ['REGULAR'],
|
||||
cancelled: false,
|
||||
changed: false,
|
||||
subjects: [{ name: 'DE', longName: 'Deutsch' }],
|
||||
teachers: [{ name: 'MEI', longName: 'Meier' }],
|
||||
rooms: [],
|
||||
classes: [],
|
||||
replaced: { subjects: [], teachers: [], rooms: [] },
|
||||
notes: {},
|
||||
homework: [],
|
||||
online: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function client(lessons: UntisLesson[], topics: Record<number, UntisTopic[]>, seen?: { periods: number[] }): UntisClient {
|
||||
return {
|
||||
async timetable(from: string, to: string): Promise<UntisTimetable> {
|
||||
const days = [...new Set(lessons.map((entry) => entry.date))].filter((date) => date >= from && date <= to);
|
||||
return { from, to, days: days.map((date) => ({ date, lessons: lessons.filter((l) => l.date === date), holidays: [] })) };
|
||||
},
|
||||
async lessonTopics(periodId: number): Promise<UntisTopic[]> {
|
||||
seen?.periods.push(periodId);
|
||||
const found = topics[periodId];
|
||||
if (!found) throw new Error(`period ${periodId} not found`);
|
||||
return found;
|
||||
},
|
||||
} as unknown as UntisClient;
|
||||
}
|
||||
|
||||
describe('chunkRange', () => {
|
||||
it('is one window for a short range', () => {
|
||||
assert.deepEqual(chunkRange('2026-09-01', '2026-09-30'), [['2026-09-01', '2026-09-30']]);
|
||||
});
|
||||
|
||||
it('splits a school year into windows the timetable call accepts', () => {
|
||||
const chunks = chunkRange('2026-01-01', '2026-12-31');
|
||||
assert.ok(chunks.length > 1);
|
||||
assert.equal(chunks[0]![0], '2026-01-01');
|
||||
assert.equal(chunks.at(-1)![1], '2026-12-31');
|
||||
// No gaps and no overlaps: every day belongs to exactly one window.
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
const previousEnd = new Date(`${chunks[i - 1]![1]}T12:00:00Z`).getTime();
|
||||
const start = new Date(`${chunks[i]![0]}T12:00:00Z`).getTime();
|
||||
assert.equal(start - previousEnd, 86_400_000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectLessonLog', () => {
|
||||
it('asks each series once, about its latest period', async () => {
|
||||
// getLessonTopic2017 answers with the lessons *before* the period given,
|
||||
// so the newest period of a series reaches all of its history and one
|
||||
// call per series covers a term.
|
||||
const seen = { periods: [] as number[] };
|
||||
const lessons = [
|
||||
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01' }),
|
||||
lesson({ periodId: 2, lessonId: 100, date: '2026-09-08' }),
|
||||
lesson({ periodId: 3, lessonId: 200, date: '2026-09-09' }),
|
||||
];
|
||||
const topics = {
|
||||
2: [{ text: 'Erörterung', periodId: 1, date: '2026-09-01', start: '08:00', end: '08:45' }],
|
||||
3: [{ text: 'Netze', periodId: 3, date: '2026-09-09', start: '08:00', end: '08:45' }],
|
||||
};
|
||||
await collectLessonLog(client(lessons, topics, seen), { from: '2026-09-01', to: '2026-09-30' });
|
||||
assert.deepEqual(seen.periods.sort(), [2, 3]);
|
||||
});
|
||||
|
||||
it('merges a topic onto the period it belongs to', async () => {
|
||||
const lessons = [lesson({ periodId: 1, lessonId: 100, date: '2026-09-01' }), lesson({ periodId: 2, lessonId: 100, date: '2026-09-08' })];
|
||||
const topics = { 2: [{ text: 'Erörterung', periodId: 1, date: '2026-09-01', start: '08:00', end: '08:45' }] };
|
||||
const log = await collectLessonLog(client(lessons, topics), { from: '2026-09-01', to: '2026-09-30' });
|
||||
assert.deepEqual(log.entries.map((entry) => [entry.periodId, entry.topic]), [[1, 'Erörterung']]);
|
||||
});
|
||||
|
||||
it('keeps a lesson that has only a teacher note, and drops the empty ones', async () => {
|
||||
const lessons = [
|
||||
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', notes: { info: 'LK am 20.09.' } }),
|
||||
lesson({ periodId: 2, lessonId: 100, date: '2026-09-08' }),
|
||||
];
|
||||
const log = await collectLessonLog(client(lessons, { 2: [] }), { from: '2026-09-01', to: '2026-09-30' });
|
||||
assert.deepEqual(log.entries.map((entry) => entry.periodId), [1]);
|
||||
assert.equal(log.periodsSeen, 2);
|
||||
});
|
||||
|
||||
it('skips cancelled periods, which taught nothing', async () => {
|
||||
const lessons = [lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', cancelled: true, notes: { info: 'Entfall' } })];
|
||||
const log = await collectLessonLog(client(lessons, {}), { from: '2026-09-01', to: '2026-09-30' });
|
||||
assert.equal(log.periodsSeen, 0);
|
||||
assert.deepEqual(log.entries, []);
|
||||
});
|
||||
|
||||
it('matches a subject on either its code or its long name', async () => {
|
||||
const lessons = [
|
||||
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', notes: { info: 'x' } }),
|
||||
lesson({ periodId: 2, lessonId: 200, date: '2026-09-01', subjects: [{ name: 'LF07', longName: 'Lernfeld 7' }], notes: { info: 'y' } }),
|
||||
];
|
||||
const byCode = await collectLessonLog(client(lessons, {}), { from: '2026-09-01', to: '2026-09-30', subject: 'lf07' });
|
||||
assert.deepEqual(byCode.entries.map((entry) => entry.periodId), [2]);
|
||||
const byName = await collectLessonLog(client(lessons, {}), { from: '2026-09-01', to: '2026-09-30', subject: 'deutsch' });
|
||||
assert.deepEqual(byName.entries.map((entry) => entry.periodId), [1]);
|
||||
});
|
||||
|
||||
it('records a refused series rather than losing the whole term', async () => {
|
||||
const lessons = [
|
||||
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', notes: { info: 'bleibt' } }),
|
||||
lesson({ periodId: 2, lessonId: 200, date: '2026-09-02', notes: { info: 'auch' } }),
|
||||
];
|
||||
// Period 2's series throws; period 1's does not.
|
||||
const log = await collectLessonLog(client(lessons, { 1: [] }), { from: '2026-09-01', to: '2026-09-30' });
|
||||
assert.equal(log.failures.length, 1);
|
||||
assert.equal(log.entries.length, 2);
|
||||
});
|
||||
|
||||
it('is newest first', async () => {
|
||||
const lessons = [
|
||||
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', notes: { info: 'a' } }),
|
||||
lesson({ periodId: 2, lessonId: 100, date: '2026-09-08', notes: { info: 'b' } }),
|
||||
];
|
||||
const log = await collectLessonLog(client(lessons, { 2: [] }), { from: '2026-09-01', to: '2026-09-30' });
|
||||
assert.deepEqual(log.entries.map((entry) => entry.date), ['2026-09-08', '2026-09-01']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lessonLogText', () => {
|
||||
it('carries the topic, the announcement and the homework into one body', () => {
|
||||
const body = lessonLogText({
|
||||
periodId: 1, lessonId: 100, date: '2026-09-01', start: '08:00', end: '08:45',
|
||||
teachers: ['Meier'], topic: 'Erörterung', notes: { info: 'LK am 20.09.' },
|
||||
homework: [{ text: 'S. 42', due: '2026-09-08' }],
|
||||
});
|
||||
assert.match(body, /Erörterung/);
|
||||
assert.match(body, /LK am 20\.09\./);
|
||||
assert.match(body, /S\. 42/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasContent', () => {
|
||||
it('is false for a lesson that recorded nothing', () => {
|
||||
assert.equal(
|
||||
hasContent({ periodId: 1, lessonId: 1, date: '2026-09-01', start: '08:00', end: '08:45', teachers: [], notes: {}, homework: [] }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
147
test/web-auth.test.ts
Normal file
147
test/web-auth.test.ts
Normal file
@@ -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<string, string>, 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user