Read the user's own lesson notes, and the class register behind them
Schulcloud says what was uploaded and WebUntis says what was scheduled. Neither says what was *taught* — which point the teacher laboured, which example landed, what "will definitely come up". That lives in two places this server could not reach: the notes the user takes in the lesson, and WebUntis' class register. Notes are a directory of Markdown files (NOTES_DIR), not a table. They have to be writable from a phone in a classroom, readable when Postgres is down, and outlive this project, and files are the only shape that is all three — so the files are the truth and the index is a view of them, the same split as file_texts and the mirror. list_notes and get_note read disk, so they answer before the first crawl; search, what_changed and all three German prompts read them alongside the Schulcloud material. add_note writes one, and is the only thing in this server that writes anything. That is not a hole in the read-only invariant but a different store: it is bounded to NOTES_DIR by the same safeComponent/resolveWithin pair that stops a hostile Schulcloud filename escaping the mirror, so a note titled ../../.ssh/authorized_keys becomes a filename. Schulcloud and WebUntis stay GET-only and allowlisted respectively. NOTES_READONLY refuses writes outright. Appending targets the *lesson*, not the title: "halt das auch noch fest" mid-lesson carries a new title, and deriving the path from it would start a second note every time, which is the one thing append exists to prevent. Notes.app has no export — its bodies are compressed protobuf and the iCloud copy is encrypted — so scripting the app is not the clumsy route to the notes but the only one. scripts/export-apple-notes.js reads them through AppleScript into one JSON object per line, and `schulcloud note import` converts the HTML to Markdown, takes the Notes folder as the subject and the *creation* date as the lesson's date. Attachments cannot come across; a note that was a photo of the board imports as a line saying so, because importing it empty would hide the loss. The class register needed one API property to become cheap: getLessonTopic2017 answers per *series*, not per period, so a term is reconstructed by asking about the latest period of each lesson series and merging back by id — a few dozen calls for a school year rather than one per lesson. untis_lesson_topics now takes a subject as well as a period id, and UNTIS_HISTORY_DAYS of register goes into the index under a kind of its own, so "what did we actually do before the test" is searchable. Sharing the snapshot rather than duplicating it caught one thing on the way: the search tool's live path had to learn notes too, or fresh=true would have quietly disagreed with the index. 305 tests; 88/89 smoke against the local instance, the one failure being the H5P service that instance does not run. The live smoke could not be retaken: that session has lapsed and needs a fresh jwt cookie. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
24
.env.example
24
.env.example
@@ -84,6 +84,22 @@ DATABASE_URL=postgresql://schulcloud:schulcloud@postgres:5432/schulcloud
|
||||
# file records are immutable.
|
||||
# CRAWL_INTERVAL_MS=21600000
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Your own lesson notes (optional — what you wrote down in class)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Directory of Markdown files holding your own notes. With it set, the server
|
||||
# offers list_notes, get_note and add_note, indexes the notes so `search` finds
|
||||
# them, and the German prompts consult them. Unset = none of that exists.
|
||||
# docker-compose.yml sets /data/notes and gives it a volume; bind-mount a synced
|
||||
# folder there instead to write notes from a phone. See docs/NOTES.md.
|
||||
# NOTES_DIR=/data/notes
|
||||
|
||||
# Leave the files alone: list_notes and get_note still work, add_note and
|
||||
# POST /api/notes are refused. Right for a deployment whose notes are synced in
|
||||
# from somewhere else and should have exactly one writer.
|
||||
# NOTES_READONLY=false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebUntis (optional — the timetable, which Schulcloud does not hold)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -106,6 +122,14 @@ DATABASE_URL=postgresql://schulcloud:schulcloud@postgres:5432/schulcloud
|
||||
# UNTIS_USER=your.username
|
||||
# UNTIS_SECRET=
|
||||
|
||||
# How far back to read the class register ("Unterrichtsinhalt", plus the notes
|
||||
# teachers leave on a period) into the search index, in days. Default 180; 0
|
||||
# turns it off. Costs one timetable call per 90 days plus one per lesson series
|
||||
# on a full crawl — a few dozen requests for a school year. This is what makes
|
||||
# "what did we actually do before the test" searchable rather than something to
|
||||
# reconstruct one tool call at a time.
|
||||
# UNTIS_HISTORY_DAYS=180
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Limits (optional — sensible defaults are built in)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
70
CLAUDE.md
70
CLAUDE.md
@@ -8,8 +8,14 @@ Read-only access to a Schulcloud (HPI Schul-Cloud / Schulcloud-Verbund-Software)
|
||||
account: courses, column boards, lessons, tasks, files with text extraction, and
|
||||
a Postgres-backed full-text index — plus the **timetable from WebUntis**, which
|
||||
is a separate system and the only place this school publishes when a lesson
|
||||
happens, or that it was cancelled. TypeScript, Node 22+,
|
||||
`@modelcontextprotocol/sdk`.
|
||||
happens, or that it was cancelled, and **the user's own lesson notes**, a
|
||||
directory of Markdown files that is the only record of what was actually said in
|
||||
the room. TypeScript, Node 22+, `@modelcontextprotocol/sdk`.
|
||||
|
||||
Three sources, and the distinction matters in every tool description: Schulcloud
|
||||
has the material, WebUntis has the schedule and the class register, the notes
|
||||
have what the teacher stressed. An answer that silently merges them is worse
|
||||
than one that says which said what.
|
||||
|
||||
Three entry points over one core:
|
||||
- `src/bin/http.ts` — Streamable HTTP + `/api`, the deployed form, behind Caddy on a Pi.
|
||||
@@ -47,10 +53,18 @@ index.
|
||||
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,
|
||||
`src/mcp/` or `src/http/` — the unit tests cover only pure functions.
|
||||
|
||||
Run smoke **both ways**: with `DATABASE_URL` set (93 checks, index-backed) and
|
||||
without (91 checks, live-only); without a WebUntis key both drop by 9, and the
|
||||
run then asserts the `untis_*` tools are *not* offered. The degradation paths are
|
||||
supported modes, not fallbacks nobody exercises. Every Schulcloud check fails with 401 when the live
|
||||
Run smoke **both ways**: with `DATABASE_URL` set (index-backed) and without
|
||||
(live-only). Without a WebUntis key the run asserts the `untis_*` tools are
|
||||
*not* offered instead of exercising them, and the same holds for `NOTES_DIR` —
|
||||
except that the smoke sets its own throwaway one, so the note tools are always
|
||||
exercised and can never touch real notes. The degradation paths are supported
|
||||
modes, not fallbacks nobody exercises.
|
||||
|
||||
The check counts are a tripwire, so re-measure them rather than trusting this
|
||||
line after a change: **88/89 against the local instance** on 2026-09-18 (the one
|
||||
failure is the H5P service, which that instance does not run). The live counts
|
||||
are stale — they were last taken before the notes and class-register work, and
|
||||
could not be retaken because the live session had lapsed. Every Schulcloud check fails with 401 when the live
|
||||
session has lapsed — check the container's keepalive log before suspecting code.
|
||||
|
||||
Store tests need a database and skip without one:
|
||||
@@ -94,6 +108,16 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
|
||||
from Profil → Freigaben, so there is no session and no keepalive on this
|
||||
side. Resolves the payload's element ids to names and returns every day in
|
||||
a range, empty ones included. See Invariants for why the allowlist is there.
|
||||
- **`notes.ts`** — the user's own lesson notes as a directory of Markdown
|
||||
files with a small frontmatter dialect. The files are the truth and the
|
||||
index is a view of them, so `list_notes`/`get_note` read disk and answer
|
||||
before the first crawl and while Postgres is down. **The one thing anything
|
||||
here writes** — see Invariants. `docs/NOTES.md` is the guide.
|
||||
- **`untis-history.ts`** — the class register read backwards, which is what
|
||||
puts "what did we actually cover" into the search index. Its whole reason
|
||||
for existing is one API property: `getLessonTopic2017` answers per *series*,
|
||||
so a term costs one call per lesson series rather than one per period — see
|
||||
API gotchas.
|
||||
- `h5p.ts` — the quizzes on a board. One GET per element, parsed into
|
||||
questions and answers; board assembly attaches it like a pad, the crawl
|
||||
indexes its text, and `get_h5p` prints it.
|
||||
@@ -121,6 +145,15 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
|
||||
- **`mcp/tools/untis.ts`** — the `untis_*` tools, registered **only** when
|
||||
`UNTIS_*` is configured: a tool that can only fail is worse than a missing
|
||||
one. `readTimetable` is shared with the prompt, the way `readCourse` is.
|
||||
`untis_lesson_topics` takes **either** a `periodId` (one series) **or** a
|
||||
`subject` (a whole term, via `untis-history.ts`); neither and both are both
|
||||
refused, because guessing which was meant is worse than asking.
|
||||
- **`mcp/tools/notes.ts`** — `list_notes`, `get_note` and `add_note`, registered
|
||||
**only** when `NOTES_DIR` is set, by the same rule as the `untis_*` tools.
|
||||
- **`mcp/prompts.ts` takes a `Sources` flag** (`{ notes, untis }`) so a prompt
|
||||
never tells Claude to call a tool this deployment does not register. A prompt
|
||||
built with no sources names none of them — that is the default, and the safe
|
||||
one.
|
||||
- **`mcp/resources.ts`, `mcp/prompts.ts`** — courses and rooms as resources a
|
||||
person attaches, carrying exactly `readCourse`/`readRoom`, the functions behind
|
||||
`get_course`/`get_room`; and three prompts, the third being
|
||||
@@ -145,6 +178,17 @@ neither the bearer nor the `jwt` cookie may go with it. `refresh_index` and `POS
|
||||
own index and mirror, and `PUT /api/token` only to the server's own token — every
|
||||
upstream call they make is still a GET.
|
||||
|
||||
**The notes directory is the only thing anything here writes to.** That is not
|
||||
an exception to the invariant above — it is a different store: the user's own
|
||||
files, never Schulcloud and never WebUntis. It is bounded by the same two
|
||||
functions as the file mirror, and for the same reason: `add_note`'s title and
|
||||
subject arrive from a tool call, become path components through `safeComponent`,
|
||||
and the result is checked by `resolveWithin`, so a note titled
|
||||
`../../.ssh/authorized_keys` becomes a filename. `NOTES_READONLY` refuses writes
|
||||
entirely. Do not widen this to anything outside `NOTES_DIR`, and do not take it
|
||||
as precedent for a Schulcloud write tool — that decision is the one above, and
|
||||
it has not changed.
|
||||
|
||||
**WebUntis is read-only by allowlist, not by verb.** Its API is JSON-RPC, so
|
||||
every call is a POST, reads included — "GET only" cannot carry over. Instead
|
||||
`core/untis.ts` holds `READ_METHODS` and `assertReadMethod` refuses anything
|
||||
@@ -354,6 +398,12 @@ cost the most:
|
||||
- **Announced tests live in `text.info`**, not in the exam module — this school
|
||||
does not use it, so `getExams2017` is always empty. That field is the most
|
||||
valuable thing in the payload.
|
||||
- **`getLessonTopic2017` answers per *series*, not per period.** Its
|
||||
`previousTopics` are the lessons *before* the period you name, each carrying
|
||||
its own `periodId`, so a term is reconstructed by taking the distinct
|
||||
`lessonId`s in a range, asking about the **latest** period of each, and
|
||||
merging back by id — a few dozen calls for a school year. Asking about the
|
||||
earliest period of a series reaches none of its history. `core/untis-history.ts`.
|
||||
- **A day with no lessons is not a holiday.** Vocational school weeks spent at
|
||||
the company simply have no periods, and `holidays` says nothing about them.
|
||||
Do not report "Ferien" for them; say there are no lessons.
|
||||
@@ -383,7 +433,8 @@ cost the most:
|
||||
3. Format output as Markdown, keeping ids visible for follow-up calls.
|
||||
4. If it reads the index, handle `context.store === undefined` with a message
|
||||
saying what is unavailable and what still works. A `untis_*` tool instead
|
||||
registers only when `context.untis` exists.
|
||||
registers only when `context.untis` exists, and a note tool only when
|
||||
`config.notesDir` is set.
|
||||
5. Add a check to `scripts/smoke.mjs` and run `npm run smoke` both ways.
|
||||
|
||||
## Resources and prompts
|
||||
@@ -410,8 +461,9 @@ bundle (2.1.272), not its docs:
|
||||
|
||||
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`, and optionally
|
||||
`MCP_CONNECTOR_TOKEN` or `MCP_PATH_SECRET`, plus the four `UNTIS_*` values (all
|
||||
four or none — a half-filled block is a paste that went wrong, so it throws);
|
||||
docker-compose sets `STATE_DIR`. See `.env.example` for the
|
||||
four or none — a half-filled block is a paste that went wrong, so it throws),
|
||||
`NOTES_DIR` and `UNTIS_HISTORY_DAYS`; docker-compose sets `STATE_DIR` and
|
||||
`NOTES_DIR`. See `.env.example` for the
|
||||
full set and `docs/AUTH.md` for refreshing the JWT — `schulcloud token set`,
|
||||
no restart. `npm run probe` and `schulcloud token` report the clocks: days until
|
||||
hard expiry and the session budget.
|
||||
|
||||
25
README.md
25
README.md
@@ -2,8 +2,9 @@
|
||||
|
||||
Read-only access to a [Schulcloud](https://github.com/hpi-schul-cloud) account —
|
||||
courses, boards, lessons, tasks and files — plus the timetable from
|
||||
[WebUntis](https://www.untis.at/), for **Claude**, via MCP, and for **you**, via
|
||||
a CLI that mirrors your coursework to disk.
|
||||
[WebUntis](https://www.untis.at/) and **the notes you take in class**, for
|
||||
**Claude**, via MCP, and for **you**, via a CLI that mirrors your coursework to
|
||||
disk.
|
||||
|
||||
Both are front ends over one core library and one live Schulcloud session, kept
|
||||
alive on a Pi.
|
||||
@@ -19,8 +20,10 @@ instance, not inferred from the upstream source.
|
||||
> *"Summarise the routing lesson from the LF10 course."*
|
||||
> *"What do I have tomorrow, and has anything been cancelled?"*
|
||||
> *"Quiz me on the DIN 5008 exercise from the DK room."*
|
||||
> *"What did we actually cover in Deutsch before the test — and what did I write down?"*
|
||||
|
||||
Twenty-eight tools, all read-only:
|
||||
Thirty-one tools. Everything that touches Schulcloud and WebUntis is read-only;
|
||||
`add_note` writes a file in your own notes directory and nowhere else.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -51,6 +54,9 @@ Twenty-eight tools, all read-only:
|
||||
| `api_get` | GET-only escape hatch for uncovered API surface |
|
||||
| `untis_timetable` | the school day from **WebUntis**: lessons, Entfall, Vertretung, room changes, period notes |
|
||||
| `untis_homework` | homework from WebUntis' class register — a separate list from Schulcloud's tasks |
|
||||
| `list_notes` | your own lesson notes — what you wrote down, by subject or date |
|
||||
| `get_note` | one of your notes in full |
|
||||
| `add_note` | write a note down during or after a lesson |
|
||||
| `untis_lesson_topics` | what previous lessons of a subject actually covered ("Unterrichtsinhalt") |
|
||||
|
||||
`download_file` extracts text from **PDF, DOCX, XLSX, PPTX and OpenDocument**
|
||||
@@ -63,6 +69,12 @@ MCP resource (`schulcloud://courses/<id>`, `schulcloud://rooms/<id>`) holding
|
||||
the same overview `get_course` and `get_room` return. In Claude Code, type `@`
|
||||
and part of the course name.
|
||||
|
||||
**Your own notes are the third source.** Schulcloud has the material and
|
||||
WebUntis has the schedule; neither has what the teacher actually stressed. Point
|
||||
`NOTES_DIR` at a directory of Markdown files and `search`, `what_changed` and
|
||||
all three prompts read it alongside everything else — including a migration path
|
||||
out of Apple Notes. See [docs/NOTES.md](docs/NOTES.md).
|
||||
|
||||
**Three ready-made prompts**, in German because the school is:
|
||||
|
||||
| | |
|
||||
@@ -87,6 +99,8 @@ schulcloud sync # mirror coursework to ~/Schulcloud
|
||||
schulcloud refresh --course <id>
|
||||
schulcloud fs tree /courses # browse the file manager ("Dateien")
|
||||
schulcloud fs get "/courses/<course>/<folder>"
|
||||
schulcloud note ls --subject Deutsch
|
||||
pbpaste | schulcloud note add --title Subnetting --subject LF07
|
||||
schulcloud token set # the monthly chore: hand the Pi a fresh Schulcloud token
|
||||
```
|
||||
|
||||
@@ -186,14 +200,15 @@ bypass, "what's new since…" — are sketched with their trade-offs in
|
||||
|
||||
```
|
||||
src/
|
||||
core/ client, types, board assembly, crawler, extraction, paths, WebUntis
|
||||
core/ client, types, board assembly, crawler, extraction, paths,
|
||||
WebUntis and its class register, your own notes
|
||||
store/ Postgres: crawl generations, diffs, full-text search
|
||||
indexer/ crawl → persist → mirror bytes → extract text → index
|
||||
mcp/ MCP server, tools, resources and prompts
|
||||
http/ express app, bearer auth, /api for the CLI
|
||||
cli/ CLI config, API client, sync engine
|
||||
bin/ http, stdio and cli entry points
|
||||
docs/ API findings, auth, deployment, CLI, roadmap
|
||||
docs/ API findings, auth, deployment, CLI, notes, roadmap
|
||||
deploy/ Caddyfile snippet, the Pi's compose file
|
||||
scripts/ probe, smoke, session diagnostics
|
||||
vendor/ upstream clones, git-ignored, for reference only
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
205
docs/NOTES.md
Normal file
205
docs/NOTES.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# Your own lesson notes
|
||||
|
||||
Schulcloud holds the material and WebUntis holds the schedule. Neither holds
|
||||
what was actually said in the room — which teacher stressed what, the example
|
||||
that finally made it click, the aside that turns up in the test. That is in
|
||||
whatever you write down during the lesson, and until now it lived somewhere no
|
||||
agent could read.
|
||||
|
||||
This is the third source: a directory of Markdown files the server reads,
|
||||
indexes and searches alongside everything else.
|
||||
|
||||
```
|
||||
NOTES_DIR/
|
||||
Deutsch/
|
||||
2026-09-15 Erörterung.md
|
||||
2026-09-22 Sprachanalyse.md
|
||||
LF07/
|
||||
2026-09-16 Subnetting.md
|
||||
Allgemein/
|
||||
2026-09-18 Elternabend.md
|
||||
```
|
||||
|
||||
## Why files
|
||||
|
||||
Three properties, in this order:
|
||||
|
||||
- **They have to be writable from a classroom.** Whatever you take notes in on a
|
||||
phone or a laptop, it can produce text files; nothing can produce rows in the
|
||||
Pi's Postgres.
|
||||
- **They have to be readable when the index is down.** `list_notes` and
|
||||
`get_note` read the disk, so they answer before the first crawl and while
|
||||
Postgres is unreachable — the index is a view of the notes, never the notes
|
||||
themselves. That is the same split as extracted file text and the mirror.
|
||||
- **They have to survive this project.** A directory of dated Markdown is
|
||||
greppable, diffable, syncable and still yours if the server is thrown away.
|
||||
|
||||
## What a note looks like
|
||||
|
||||
Frontmatter, then the note. Every field is optional, and a plain `.md` file with
|
||||
no frontmatter at all is a perfectly good note.
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Erörterung — Aufbau
|
||||
date: 2026-09-15
|
||||
subject: Deutsch
|
||||
tags: [klausur, aufsatz]
|
||||
---
|
||||
|
||||
Dreischritt: These, Argument mit Beleg, Fazit.
|
||||
|
||||
Frau Meier betont: das **Gegenargument** darf nicht fehlen, sonst gibt es
|
||||
Abzug — kam letztes Jahr in der Arbeit dran.
|
||||
```
|
||||
|
||||
What the server reads out of it:
|
||||
|
||||
| Field | Falls back to |
|
||||
|---|---|
|
||||
| `title` | the first `# heading`, else the filename without its date |
|
||||
| `date` | a `YYYY-MM-DD` the **filename** starts with, else nothing |
|
||||
| `subject` | the first folder name (`Deutsch/…`) |
|
||||
| `tags` | none |
|
||||
| `courseId` | none — set it to tie a note to a Schulcloud course in `search` |
|
||||
|
||||
`date` also accepts `15.09.2026`, and `fach:` works as a German spelling of
|
||||
`subject:`. Anything else in the block is kept but not interpreted.
|
||||
|
||||
**The file's modification time is never used as the date.** An import writes
|
||||
every note today; dating a year of lessons "today" would make the whole store
|
||||
useless for revision, which is most of what it is for.
|
||||
|
||||
## Reading them
|
||||
|
||||
| Tool | Answers |
|
||||
|---|---|
|
||||
| `list_notes` | "what did I write down in Deutsch before the test" — filter by subject or date |
|
||||
| `get_note` | one note in full, by the path everything else prints |
|
||||
| `search` | notes by their contents, next to board text and the inside of PDFs |
|
||||
| `what_changed` | notes that appeared or were edited since a date |
|
||||
|
||||
The `pruefungsvorbereitung`, `zusammenfassung` and `tagesvorbereitung` prompts
|
||||
consult them on their own, and are told to say when a note disagrees with the
|
||||
uploaded material rather than quietly preferring one.
|
||||
|
||||
## Writing them
|
||||
|
||||
Four ways in, all landing in the same files:
|
||||
|
||||
```bash
|
||||
# from the command line, text piped in
|
||||
pbpaste | schulcloud note add --title "Subnetting" --subject LF07
|
||||
|
||||
# from an editor, or anything else that writes files
|
||||
$EDITOR "$NOTES_DIR/LF07/2026-09-16 Subnetting.md"
|
||||
|
||||
# by asking Claude, during or after the lesson
|
||||
# "halt fest: Gegenargument nicht vergessen, kam letztes Jahr dran"
|
||||
# → add_note, subject Deutsch, today's date
|
||||
|
||||
# by syncing a folder you already write in — see below
|
||||
```
|
||||
|
||||
`add_note` and `schulcloud note add` take `append`, which adds to the note
|
||||
already written for that subject and day instead of starting a second one. That
|
||||
is the right choice while a lesson is running: notes accumulate in one file the
|
||||
way they do on paper.
|
||||
|
||||
**This is the only thing in this server that writes anything.** It writes into
|
||||
the notes directory and nowhere else: every path component goes through
|
||||
`safeComponent` and the result through `resolveWithin`, the same two functions
|
||||
that stop a hostile filename escaping the file mirror, so a note titled
|
||||
`../../.ssh/authorized_keys` becomes a filename. Schulcloud and WebUntis stay
|
||||
strictly read-only — see the invariants in `CLAUDE.md`. Set `NOTES_READONLY=1`
|
||||
to refuse writes entirely, which is right when the notes are synced in from
|
||||
somewhere else and should have exactly one writer.
|
||||
|
||||
## Migrating out of Apple Notes
|
||||
|
||||
Notes.app has no export. Its database is a Core Data store whose bodies are
|
||||
compressed protobuf and whose iCloud copy is encrypted, so scripting the app is
|
||||
not the clumsy route to your notes — it is the only one.
|
||||
|
||||
**On the Mac**, from a checkout of this repo:
|
||||
|
||||
```bash
|
||||
osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson
|
||||
```
|
||||
|
||||
The first run raises a macOS permission dialog ("Terminal wants access to
|
||||
Notes"); without it every note comes back empty. `--folder Deutsch` exports one
|
||||
Notes folder.
|
||||
|
||||
Then convert and import. Look at it first:
|
||||
|
||||
```bash
|
||||
schulcloud note import notes.ndjson --dry-run
|
||||
```
|
||||
|
||||
```
|
||||
would import: 2026-09-15 · Deutsch · Erörterung
|
||||
would import: 2026-09-16 · LF07 · Subnetting
|
||||
Would import 84 of 91 note(s), skipped 5 with no text, 2 unreadable in Notes.
|
||||
```
|
||||
|
||||
and then for real, either straight into the server:
|
||||
|
||||
```bash
|
||||
schulcloud note import notes.ndjson
|
||||
```
|
||||
|
||||
or into a local directory, to read through before anything is sent anywhere:
|
||||
|
||||
```bash
|
||||
schulcloud note import notes.ndjson --out ~/Notizen
|
||||
```
|
||||
|
||||
What the import does with each note:
|
||||
|
||||
- **The Notes folder becomes the subject** — its last segment, so `Schule/Deutsch`
|
||||
is `Deutsch`. Notes' own default folders (`Notizen`, `Recently Deleted`) are
|
||||
ignored rather than becoming a subject. `--subject LF07` overrides all of it.
|
||||
- **The creation date becomes the note's date**, because that is the day of the
|
||||
lesson. The modification date is whenever you last tidied it up, which is not
|
||||
a school day at all.
|
||||
- **The HTML becomes Markdown** — headings, lists, checklists, bold, italics and
|
||||
links survive; anything else keeps its words and loses its tag.
|
||||
- **Attachments do not come across.** A note that was a photo of the board
|
||||
imports as a line saying an attachment was there. Better than importing empty:
|
||||
you can see which notes still need the picture.
|
||||
- **Locked notes cannot be read at all** and are listed by name at the end.
|
||||
Unlock them in Notes and export again.
|
||||
|
||||
Re-running the import creates second copies rather than overwriting, since the
|
||||
importer cannot tell an edited note from a new one with the same title. Import
|
||||
once, then keep writing in the new place.
|
||||
|
||||
## Writing them from a phone, afterwards
|
||||
|
||||
The notes are files, so any sync tool will do and the server does not need to
|
||||
know which. Bind-mount the folder instead of using the volume:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml, under schulcloud-mcp:
|
||||
volumes:
|
||||
- /home/pi/Notizen:/data/notes
|
||||
```
|
||||
|
||||
Then point Syncthing, Nextcloud, an Obsidian vault or `git` at
|
||||
`/home/pi/Notizen` on one side and your phone on the other. New files are picked
|
||||
up by the next crawl; nothing has to be told about them.
|
||||
|
||||
Without any of that, `schulcloud note add` and `add_note` still work — they go
|
||||
over the same authenticated `/api` as everything else the CLI does.
|
||||
|
||||
## What the crawl does with them
|
||||
|
||||
A **full** crawl reads the directory and indexes every note: title, subject,
|
||||
tags and body, under the kind `note`, with its path as its id. A **per-course**
|
||||
refresh leaves them alone — notes belong to the account, not to a course — and
|
||||
the store carries the previous generation's rows forward, so a per-course crawl
|
||||
never looks like the notes were deleted.
|
||||
|
||||
Notes are diffed by content, not by modification time, so a sync tool that
|
||||
rewrites a file byte-for-byte does not show up in `what_changed` as an edit.
|
||||
@@ -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
|
||||
|
||||
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,11 @@ process.env.MCP_CONNECTOR_TOKEN = CONNECTOR_TOKEN;
|
||||
// A state directory of its own, so the run can neither read nor leave a saved token.
|
||||
const STATE_DIR = await mkdtemp(join(tmpdir(), 'schulcloud-smoke-state-'));
|
||||
process.env.STATE_DIR = STATE_DIR;
|
||||
// And a notes directory of its own. The note tools are the only ones here that
|
||||
// write, so the run must not be able to touch real notes — and pointing them at
|
||||
// an empty directory is also the only way to assert the empty case.
|
||||
const NOTES_DIR = await mkdtemp(join(tmpdir(), 'schulcloud-smoke-notes-'));
|
||||
process.env.NOTES_DIR = NOTES_DIR;
|
||||
// The app is bound by this script on an ephemeral port, so config.port is unused.
|
||||
|
||||
const config = loadConfig();
|
||||
@@ -480,6 +485,73 @@ if (taskId) {
|
||||
check('list_submissions unscoped', !all.isError, all.text.split('\n')[0]);
|
||||
}
|
||||
|
||||
console.log('\n== own notes ==');
|
||||
// The user's own lesson notes: the one store here that is neither Schulcloud's
|
||||
// nor WebUntis', and the one thing this server can write. Every check runs
|
||||
// against the throwaway NOTES_DIR above.
|
||||
{
|
||||
const noteTools = names.filter((name) => ['list_notes', 'get_note', 'add_note'].includes(name));
|
||||
check('the note tools are offered when NOTES_DIR is set', noteTools.length === 3, noteTools.join(', ') || 'none');
|
||||
|
||||
const empty = await call('list_notes');
|
||||
check(
|
||||
'an empty notes directory is explained, not reported as a failure',
|
||||
!empty.isError && /no notes yet/i.test(empty.text),
|
||||
empty.text.split('\n')[0],
|
||||
);
|
||||
|
||||
const added = await call('add_note', {
|
||||
title: 'Erörterung',
|
||||
text: 'Dreischritt: These, Argument, Fazit. Gegenargument nicht vergessen.',
|
||||
subject: 'Deutsch',
|
||||
date: '2026-09-15',
|
||||
tags: ['klausur'],
|
||||
});
|
||||
check('add_note saves a note', !added.isError && /Deutsch\/2026-09-15 Erörterung\.md/.test(added.text), added.text.split('\n')[0]);
|
||||
|
||||
const listed = await call('list_notes', { subject: 'deut' });
|
||||
check('list_notes finds it by a fragment of the subject', !listed.isError && /Erörterung/.test(listed.text), listed.text.split('\n')[0]);
|
||||
|
||||
const one = await call('get_note', { path: 'Deutsch/2026-09-15 Erörterung.md' });
|
||||
check('get_note returns the note in full', !one.isError && /Gegenargument/.test(one.text), one.text.split('\n')[0]);
|
||||
|
||||
const appended = await call('add_note', {
|
||||
title: 'Nachtrag',
|
||||
text: 'Beispiel: Handyverbot an Schulen.',
|
||||
subject: 'Deutsch',
|
||||
date: '2026-09-15',
|
||||
append: true,
|
||||
});
|
||||
const afterAppend = await call('get_note', { path: 'Deutsch/2026-09-15 Erörterung.md' });
|
||||
check(
|
||||
'append adds to the same note rather than starting a second one',
|
||||
!appended.isError && /Handyverbot/.test(afterAppend.text) && /Gegenargument/.test(afterAppend.text),
|
||||
appended.text.split('\n')[0],
|
||||
);
|
||||
|
||||
const missing = await call('get_note', { path: 'Deutsch/gibt-es-nicht.md' });
|
||||
check('a missing note is a tool error naming the path', missing.isError && /no note at/i.test(missing.text), missing.text.split('\n')[0]);
|
||||
|
||||
// The title reaches the filesystem, so it is untrusted input at exactly the
|
||||
// boundary core/paths.ts exists to guard.
|
||||
const hostile = await call('add_note', { title: '../../../etc/passwd', text: 'x', subject: '../..', date: '2026-09-15' });
|
||||
// The title is echoed back verbatim — it is the user's own — so the check is
|
||||
// on the path the note actually landed at, in backticks.
|
||||
const hostilePath = hostile.text.match(/`([^`]+)`/)?.[1] ?? '';
|
||||
check(
|
||||
'a note cannot be written outside the notes directory',
|
||||
!hostile.isError && hostilePath.length > 0 && !hostilePath.split('/').includes('..'),
|
||||
hostilePath,
|
||||
);
|
||||
|
||||
const fresh = await call('search', { query: 'Gegenargument', fresh: true, courseId: courseIds[0] });
|
||||
check(
|
||||
'a live search reads the notes too, so it agrees with the index',
|
||||
!fresh.isError && /Gegenargument/.test(fresh.text),
|
||||
fresh.text.split('\n')[0],
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n== index tools ==');
|
||||
// These degrade gracefully without DATABASE_URL, so assert on either outcome
|
||||
// rather than requiring a database for the smoke run to be meaningful.
|
||||
@@ -509,6 +581,13 @@ if (hasIndex) {
|
||||
!indexed.isError && /refreshed/i.test(indexed.text),
|
||||
indexed.text.split('\n')[0],
|
||||
);
|
||||
|
||||
// Notes are only picked up by a *full* crawl, and a full crawl walks every
|
||||
// course and every file-manager folder — minutes, not seconds. Indexing them
|
||||
// is covered by test/store.test.ts against a real Postgres instead; what the
|
||||
// smoke checks here is that the kind filter exists and answers.
|
||||
const byKind = await call('search', { query: 'Gegenargument', kinds: ['note'] });
|
||||
check('search accepts the note kind', !byKind.isError, byKind.text.split('\n')[0]);
|
||||
}
|
||||
|
||||
console.log('\n== WebUntis ==');
|
||||
@@ -580,6 +659,26 @@ if (hasUntis) {
|
||||
check('untis_lesson_topics reads what previous lessons covered', true, 'skipped: no lesson in the window');
|
||||
}
|
||||
|
||||
// The subject form is the one that reconstructs a term without a period id.
|
||||
const subject = month.text.match(/\*\*([A-Za-zÄÖÜäöü0-9]{2,10})\*\*/)?.[1];
|
||||
if (subject) {
|
||||
const bySubject = await call('untis_lesson_topics', { subject, from: '2026-06-01', to: end, limit: 5 });
|
||||
check(
|
||||
`untis_lesson_topics reads a whole term by subject ("${subject}")`,
|
||||
!bySubject.isError && (/Unterricht „/.test(bySubject.text) || /No lessons of|nothing was recorded/.test(bySubject.text)),
|
||||
bySubject.text.split('\n')[0],
|
||||
);
|
||||
} else {
|
||||
check('untis_lesson_topics reads a whole term by subject', true, 'skipped: no subject in the window');
|
||||
}
|
||||
|
||||
const neither = await call('untis_lesson_topics', {});
|
||||
check(
|
||||
'untis_lesson_topics asks for a subject or a period, not neither',
|
||||
neither.isError && /subject/.test(neither.text),
|
||||
neither.text.split('\n')[0],
|
||||
);
|
||||
|
||||
const unreal = await call('untis_timetable', { from: '2026-02-30' });
|
||||
check(
|
||||
'a date that does not exist is refused rather than rolled over',
|
||||
@@ -713,6 +812,7 @@ await client.close();
|
||||
httpServer.close();
|
||||
await closeServices(services);
|
||||
await rm(STATE_DIR, { recursive: true, force: true });
|
||||
await rm(NOTES_DIR, { recursive: true, force: true });
|
||||
|
||||
console.log(`\n${results.length - failures}/${results.length} checks passed`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
|
||||
@@ -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,25 @@ export interface Config {
|
||||
/** How often to re-crawl on a timer. Zero = only on demand. */
|
||||
crawlIntervalMs: number;
|
||||
|
||||
/**
|
||||
* Where the user's own lesson notes live, as Markdown files. Unset = the
|
||||
* note tools are not offered, the same rule the untis_* tools follow.
|
||||
*/
|
||||
notesDir: string | undefined;
|
||||
/**
|
||||
* Whether add_note may write. The notes directory is the only thing in this
|
||||
* server anything can write to, so turning it off is a real setting and not
|
||||
* a theoretical one — a deployment that syncs its notes in from elsewhere
|
||||
* wants the files left alone.
|
||||
*/
|
||||
notesWritable: boolean;
|
||||
/**
|
||||
* How far back to read the WebUntis class register into the index. Zero =
|
||||
* not at all. Costs one timetable call per 90 days plus one per lesson
|
||||
* series, so a school year is a few dozen requests on a background crawl.
|
||||
*/
|
||||
untisHistoryDays: number;
|
||||
|
||||
/**
|
||||
* WebUntis, where the school keeps the timetable. Unset = the untis_* tools
|
||||
* are not offered at all, which is the right answer for a school that does
|
||||
@@ -210,6 +229,11 @@ export function loadConfig(): Config {
|
||||
// so an index without it misses whole courses. One page load per folder.
|
||||
indexFileManager: bool('INDEX_FILE_MANAGER', true),
|
||||
crawlIntervalMs: intAllowingZero('CRAWL_INTERVAL_MS', 6 * 60 * 60_000),
|
||||
// Absolute for the same reason as the mirror: resolveWithin only returns
|
||||
// an absolute path when the root it is given is one.
|
||||
notesDir: process.env.NOTES_DIR?.trim() ? resolve(process.env.NOTES_DIR.trim()) : undefined,
|
||||
notesWritable: !bool('NOTES_READONLY', false),
|
||||
untisHistoryDays: intAllowingZero('UNTIS_HISTORY_DAYS', 180),
|
||||
untis: untisConfig(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CrawledBoard, Snapshot } from './crawl.ts';
|
||||
import { h5pSearchText } from './h5p.ts';
|
||||
import { noteSearchText } from './notes.ts';
|
||||
import { matchesAll, snippet, tokenize } from './text.ts';
|
||||
|
||||
/**
|
||||
@@ -18,7 +19,7 @@ export interface Hit {
|
||||
where: string;
|
||||
/** Id to pass to a follow-up tool, with the tool that takes it. */
|
||||
targetId: string;
|
||||
targetKind: 'course' | 'room' | 'board' | 'lesson' | 'task' | 'file';
|
||||
targetKind: 'course' | 'room' | 'board' | 'lesson' | 'task' | 'file' | 'note';
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
@@ -114,6 +115,22 @@ export function searchSnapshot(snapshot: Snapshot, query: string, limit = 50): H
|
||||
matchBoards(room.boards, base, terms, push);
|
||||
}
|
||||
|
||||
// The user's own notes. `courseId` is the subject rather than an id: nothing
|
||||
// follows a note back to a course, and the subject is what makes the hit
|
||||
// readable — "Deutsch — my note" rather than a bare path.
|
||||
for (const note of snapshot.notes) {
|
||||
const haystack = noteSearchText(note);
|
||||
if (!matchesAll(haystack, terms)) continue;
|
||||
hits.push({
|
||||
courseId: note.courseId ?? '',
|
||||
courseTitle: note.subject ?? 'Notizen',
|
||||
where: `my own note${note.date ? `, ${note.date}` : ''}`,
|
||||
targetId: note.path,
|
||||
targetKind: 'note',
|
||||
snippet: snippet(haystack, terms),
|
||||
});
|
||||
}
|
||||
|
||||
for (const file of snapshot.files) {
|
||||
if (matchesAll(file.record.name, terms)) {
|
||||
hits.push({
|
||||
|
||||
466
src/core/notes.ts
Normal file
466
src/core/notes.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
import { readdir, readFile, stat, mkdir, writeFile, appendFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { schoolToday } from './dates.ts';
|
||||
import { resolveWithin, safeComponent } from './paths.ts';
|
||||
|
||||
/**
|
||||
* The user's own lesson notes: a directory of Markdown files.
|
||||
*
|
||||
* This is the one store here that Schulcloud and WebUntis know nothing about —
|
||||
* what the person in the room wrote down. It exists because the two upstreams
|
||||
* between them still do not answer "what did the teacher actually say", and a
|
||||
* note taken in the lesson is often the only record of it.
|
||||
*
|
||||
* **Plain files, not a table.** The notes have to be writable from a phone in a
|
||||
* classroom and readable when Postgres is down, so the files are the truth and
|
||||
* the index is only a view of them — the same split as `file_texts` and the
|
||||
* mirror. It also makes migrating in a pile of exported Apple Notes a matter of
|
||||
* writing files, and makes the whole store greppable, diffable and syncable by
|
||||
* anything the user already runs.
|
||||
*
|
||||
* Frontmatter is a deliberately small YAML subset (scalars and inline lists),
|
||||
* parsed here rather than by a dependency: notes are hand-written, so a strict
|
||||
* parser that rejects a file is worse than a lax one that keeps the body. A
|
||||
* file with no frontmatter at all is a valid note.
|
||||
*/
|
||||
|
||||
/** Extensions treated as notes. Anything else in the directory is ignored. */
|
||||
const NOTE_EXTENSIONS = ['.md', '.markdown', '.txt'];
|
||||
|
||||
/**
|
||||
* Caps. A notes directory is user-controlled, but it may also be a synced
|
||||
* folder that has just acquired somebody's 400 MB export, and a crawl must not
|
||||
* turn that into an out-of-memory.
|
||||
*/
|
||||
const MAX_NOTE_BYTES = 512 * 1024;
|
||||
const MAX_NOTES = 5_000;
|
||||
const MAX_DEPTH = 8;
|
||||
|
||||
export interface NoteDoc {
|
||||
/**
|
||||
* Path relative to the notes root — `Deutsch/2026-09-15 Erörterung.md`.
|
||||
* This is the note's id: there is no other, and it is what `get_note` takes.
|
||||
*/
|
||||
path: string;
|
||||
title: string;
|
||||
/** The school day the note belongs to, `YYYY-MM-DD`, when it could be determined. */
|
||||
date?: string;
|
||||
/** Free text as the user writes it — "Deutsch", "LF07". Not a Schulcloud id. */
|
||||
subject?: string;
|
||||
/** A Schulcloud course id, when the note names one, so search can group by course. */
|
||||
courseId?: string;
|
||||
tags: string[];
|
||||
/** Where the note came from: `apple-notes`, `add_note`, or absent for a hand-written file. */
|
||||
source?: string;
|
||||
/** The body, without the frontmatter block. */
|
||||
text: string;
|
||||
/** Last write to the file, ISO. Not the lesson date — see `date` for that. */
|
||||
modifiedAt: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface NoteFrontmatter {
|
||||
title?: string;
|
||||
date?: string;
|
||||
subject?: string;
|
||||
courseId?: string;
|
||||
tags?: string[];
|
||||
source?: string;
|
||||
/** Anything else the file carried, preserved so a round trip loses nothing. */
|
||||
extra?: Record<string, string>;
|
||||
}
|
||||
|
||||
// --- reading -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Every note under `root`, newest lesson first.
|
||||
*
|
||||
* Never throws for a missing root: a notes directory that has not been created
|
||||
* yet is an empty one, and the tools say so far better than a crawl that dies.
|
||||
*/
|
||||
export async function readNotes(root: string): Promise<NoteDoc[]> {
|
||||
const paths = await listNotePaths(root);
|
||||
const notes: NoteDoc[] = [];
|
||||
for (const relative of paths) {
|
||||
const note = await readNoteAt(root, relative).catch(() => undefined);
|
||||
if (note) notes.push(note);
|
||||
}
|
||||
return notes.sort(byNewest);
|
||||
}
|
||||
|
||||
/** Relative paths of the note files under `root`, sorted for a stable order. */
|
||||
export async function listNotePaths(root: string): Promise<string[]> {
|
||||
const found: string[] = [];
|
||||
|
||||
const walk = async (relative: string, depth: number): Promise<void> => {
|
||||
if (depth > MAX_DEPTH || found.length >= MAX_NOTES) return;
|
||||
const absolute = relative ? resolveWithin(root, relative) : root;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(absolute, { withFileTypes: true });
|
||||
} catch {
|
||||
// A root that does not exist yet, or a folder we may not read: an
|
||||
// unreadable corner must not cost the notes that are readable.
|
||||
return;
|
||||
}
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
// Dotfiles are the sync tools' own business (.obsidian, .git, .stfolder)
|
||||
// and never a note.
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const child = relative ? `${relative}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) await walk(child, depth + 1);
|
||||
else if (isNoteFile(entry.name) && found.length < MAX_NOTES) found.push(child);
|
||||
}
|
||||
};
|
||||
|
||||
await walk('', 0);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** One note by its relative path. Throws `NoteNotFound` when there is none. */
|
||||
export async function readNoteAt(root: string, relative: string): Promise<NoteDoc> {
|
||||
const absolute = resolveWithin(root, normalizeRelative(relative));
|
||||
let info;
|
||||
try {
|
||||
info = await stat(absolute);
|
||||
} catch {
|
||||
throw new NoteNotFound(relative);
|
||||
}
|
||||
if (!info.isFile()) throw new NoteNotFound(relative);
|
||||
if (info.size > MAX_NOTE_BYTES) {
|
||||
throw new Error(
|
||||
`Note ${relative} is ${Math.round(info.size / 1024)} KB, past the ${MAX_NOTE_BYTES / 1024} KB limit for a note.`,
|
||||
);
|
||||
}
|
||||
const raw = await readFile(absolute, 'utf8');
|
||||
return parseNote(normalizeRelative(relative), raw, { modifiedAt: info.mtime.toISOString(), bytes: info.size });
|
||||
}
|
||||
|
||||
export class NoteNotFound extends Error {
|
||||
readonly path: string;
|
||||
|
||||
constructor(path: string) {
|
||||
super(`No note at "${path}".`);
|
||||
this.name = 'NoteNotFound';
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A file's text as a note.
|
||||
*
|
||||
* Pure, so the whole frontmatter/title/date story is testable without a disk.
|
||||
*/
|
||||
export function parseNote(
|
||||
relative: string,
|
||||
raw: string,
|
||||
stamp: { modifiedAt: string; bytes: number },
|
||||
): NoteDoc {
|
||||
const { front, body } = splitFrontmatter(raw);
|
||||
const fileName = relative.split('/').pop() ?? relative;
|
||||
return {
|
||||
path: relative,
|
||||
title: front.title || headingTitle(body) || titleFromFileName(fileName),
|
||||
// Frontmatter first, then a date the filename starts with. Never the
|
||||
// file's mtime: an import writes every note today, and dating a year of
|
||||
// lessons "today" would make the whole store useless for "what did we do
|
||||
// before the test".
|
||||
...pick('date', front.date ?? dateFromFileName(fileName)),
|
||||
...pick('subject', front.subject ?? subjectFromPath(relative)),
|
||||
...pick('courseId', front.courseId),
|
||||
...pick('source', front.source),
|
||||
tags: front.tags ?? [],
|
||||
text: body.trim(),
|
||||
modifiedAt: stamp.modifiedAt,
|
||||
bytes: stamp.bytes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits `---\nkey: value\n---\n` off the front.
|
||||
*
|
||||
* Only a leading block counts, and only when it closes: a note that happens to
|
||||
* begin with a horizontal rule keeps its text rather than losing half of it.
|
||||
*/
|
||||
export function splitFrontmatter(raw: string): { front: NoteFrontmatter; body: string } {
|
||||
const text = raw.replace(/^\ufeff/, '');
|
||||
const open = /^---[ \t]*\r?\n/.exec(text);
|
||||
if (!open) return { front: {}, body: text };
|
||||
const close = /\r?\n---[ \t]*(\r?\n|$)/.exec(text.slice(open[0].length - 1));
|
||||
if (!close) return { front: {}, body: text };
|
||||
|
||||
const end = open[0].length - 1 + close.index;
|
||||
const block = text.slice(open[0].length, end);
|
||||
const rest = text.slice(end + close[0].length);
|
||||
|
||||
const front: NoteFrontmatter = {};
|
||||
const extra: Record<string, string> = {};
|
||||
for (const line of block.split(/\r?\n/)) {
|
||||
const match = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line.trim());
|
||||
if (!match) continue;
|
||||
const key = match[1]!.toLowerCase();
|
||||
const value = unquote(match[2]!.trim());
|
||||
if (!value) continue;
|
||||
switch (key) {
|
||||
case 'title':
|
||||
front.title = value;
|
||||
break;
|
||||
case 'date':
|
||||
front.date = normalizeDate(value);
|
||||
break;
|
||||
case 'subject':
|
||||
case 'fach':
|
||||
front.subject = value;
|
||||
break;
|
||||
case 'courseid':
|
||||
case 'course':
|
||||
front.courseId = value;
|
||||
break;
|
||||
case 'source':
|
||||
front.source = value;
|
||||
break;
|
||||
case 'tags':
|
||||
front.tags = parseList(value);
|
||||
break;
|
||||
default:
|
||||
extra[key] = value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(extra).length > 0) front.extra = extra;
|
||||
return { front, body: rest };
|
||||
}
|
||||
|
||||
// --- writing -------------------------------------------------------------
|
||||
|
||||
export interface NoteInput {
|
||||
title: string;
|
||||
text: string;
|
||||
/** The lesson's day. Defaults to today in the school's timezone. */
|
||||
date?: string;
|
||||
subject?: string;
|
||||
courseId?: string;
|
||||
tags?: string[];
|
||||
source?: string;
|
||||
/** Write here instead of deriving a path from subject, date and title. */
|
||||
path?: string;
|
||||
/**
|
||||
* Add to the note at that path if it already exists, rather than creating a
|
||||
* second one. This is what makes a lesson's notes accumulate in one file as
|
||||
* they are taken, which is how anyone actually takes them.
|
||||
*/
|
||||
append?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a note, or appends to one.
|
||||
*
|
||||
* Every component of the path goes through `safeComponent`: the title and
|
||||
* subject arrive from a tool call, so they are untrusted input that becomes a
|
||||
* filename, exactly as course titles do in the mirror.
|
||||
*/
|
||||
export async function writeNote(root: string, input: NoteInput): Promise<{ note: NoteDoc; appended: boolean }> {
|
||||
const date = input.date ?? schoolToday();
|
||||
// Appending is about a *lesson*, not about a title: "note this down too" in
|
||||
// the middle of Tuesday's German lesson means the note already open for
|
||||
// Tuesday and German, whatever it happens to be called. Deriving the path
|
||||
// from the new title instead would start a second note every time, which is
|
||||
// the one thing append exists to prevent.
|
||||
const relative = input.path
|
||||
? normalizeRelative(input.path)
|
||||
: ((input.append ? await noteForLesson(root, date, input.subject) : undefined) ??
|
||||
notePathFor({ date, subject: input.subject, title: input.title }));
|
||||
const absolute = resolveWithin(root, relative);
|
||||
|
||||
const existing = await stat(absolute).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
if (existing && input.append) {
|
||||
// A heading rather than a bare paragraph, so a note built from four
|
||||
// appends still reads as four things and not as one run-on.
|
||||
await appendFile(absolute, `\n\n## ${input.title}\n\n${input.text.trim()}\n`, 'utf8');
|
||||
return { note: await readNoteAt(root, relative), appended: true };
|
||||
}
|
||||
|
||||
// Never overwrite: a note is the only copy of what someone wrote down, and a
|
||||
// second note with the same title on the same day is a normal thing to have.
|
||||
const target = existing ? await freePath(root, relative) : relative;
|
||||
await mkdir(dirname(resolveWithin(root, target)), { recursive: true });
|
||||
await writeFile(
|
||||
resolveWithin(root, target),
|
||||
renderNote(
|
||||
{
|
||||
title: input.title,
|
||||
date,
|
||||
...pick('subject', input.subject),
|
||||
...pick('courseId', input.courseId),
|
||||
...pick('source', input.source),
|
||||
...(input.tags && input.tags.length > 0 ? { tags: input.tags } : {}),
|
||||
},
|
||||
input.text,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
return { note: await readNoteAt(root, target), appended: false };
|
||||
}
|
||||
|
||||
/** A note as it is stored: frontmatter, then the body. */
|
||||
export function renderNote(front: NoteFrontmatter, body: string): string {
|
||||
const lines = [
|
||||
front.title !== undefined && `title: ${quote(front.title)}`,
|
||||
front.date !== undefined && `date: ${front.date}`,
|
||||
front.subject !== undefined && `subject: ${quote(front.subject)}`,
|
||||
front.courseId !== undefined && `courseId: ${front.courseId}`,
|
||||
front.tags && front.tags.length > 0 && `tags: [${front.tags.map((tag) => quote(tag)).join(', ')}]`,
|
||||
front.source !== undefined && `source: ${quote(front.source)}`,
|
||||
...Object.entries(front.extra ?? {}).map(([key, value]) => `${key}: ${quote(value)}`),
|
||||
].filter((line): line is string => typeof line === 'string');
|
||||
return `---\n${lines.join('\n')}\n---\n\n${body.trim()}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a new note goes: `Deutsch/2026-09-15 Erörterung.md`.
|
||||
*
|
||||
* Subject-first because that is how anyone looks for a note by hand, and the
|
||||
* date leads the filename so a folder sorts chronologically in every file
|
||||
* browser there is.
|
||||
*/
|
||||
export function notePathFor(input: { date: string; subject?: string; title: string }): string {
|
||||
const folder = safeComponent(input.subject ?? 'Allgemein', 'Allgemein');
|
||||
const name = safeComponent(`${input.date} ${input.title}`, input.date);
|
||||
return `${folder}/${name}.md`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The note already written for this day and subject, if there is one.
|
||||
*
|
||||
* The newest by path, so a day that somehow grew two notes still gets the one
|
||||
* a person would reach for.
|
||||
*/
|
||||
async function noteForLesson(root: string, date: string, subject: string | undefined): Promise<string | undefined> {
|
||||
const wanted = subject?.trim().toLowerCase();
|
||||
const candidates = (await readNotes(root)).filter(
|
||||
(note) => note.date === date && (note.subject ?? '').toLowerCase() === (wanted ?? ''),
|
||||
);
|
||||
return candidates[0]?.path;
|
||||
}
|
||||
|
||||
/** `note.md` → `note 2.md`, for the day someone titles two notes the same. */
|
||||
async function freePath(root: string, relative: string): Promise<string> {
|
||||
const dot = relative.lastIndexOf('.');
|
||||
const stem = dot > 0 ? relative.slice(0, dot) : relative;
|
||||
const ext = dot > 0 ? relative.slice(dot) : '';
|
||||
for (let n = 2; n < 100; n++) {
|
||||
const candidate = `${stem} ${n}${ext}`;
|
||||
const taken = await stat(resolveWithin(root, candidate)).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (!taken) return candidate;
|
||||
}
|
||||
throw new Error(`Too many notes named like ${relative}.`);
|
||||
}
|
||||
|
||||
// --- matching ------------------------------------------------------------
|
||||
|
||||
/** Everything about a note that search should look at, as one string. */
|
||||
export function noteSearchText(note: NoteDoc): string {
|
||||
return [note.title, note.subject, note.tags.join(' '), note.text].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
/** Filters a list the way `list_notes` does. Pure, and shared with the CLI. */
|
||||
export function filterNotes(
|
||||
notes: NoteDoc[],
|
||||
filter: { subject?: string; since?: string; until?: string; courseId?: string },
|
||||
): NoteDoc[] {
|
||||
const subject = filter.subject?.trim().toLowerCase();
|
||||
return notes.filter((note) => {
|
||||
if (subject && !(note.subject ?? '').toLowerCase().includes(subject)) return false;
|
||||
if (filter.courseId && note.courseId !== filter.courseId) return false;
|
||||
// A note with no date cannot be excluded by a date window without
|
||||
// silently hiding it; undated notes always pass.
|
||||
if (filter.since && note.date && note.date < filter.since) return false;
|
||||
if (filter.until && note.date && note.date > filter.until) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// --- helpers -------------------------------------------------------------
|
||||
|
||||
function isNoteFile(name: string): boolean {
|
||||
const lower = name.toLowerCase();
|
||||
return NOTE_EXTENSIONS.some((extension) => lower.endsWith(extension));
|
||||
}
|
||||
|
||||
/**
|
||||
* A caller's path in the one form the rest of this module uses.
|
||||
*
|
||||
* Leading slashes and backslashes are accepted and normalised because people
|
||||
* paste `/Deutsch/…` from a listing; traversal is not — `resolveWithin` refuses
|
||||
* it, and this must not quietly make it look legal first.
|
||||
*/
|
||||
export function normalizeRelative(path: string): string {
|
||||
return path.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/').trim();
|
||||
}
|
||||
|
||||
function byNewest(a: NoteDoc, b: NoteDoc): number {
|
||||
// Undated notes sort last: they are usually imports that never carried a
|
||||
// date, and they should not head a list of "the last few lessons".
|
||||
if (a.date && b.date && a.date !== b.date) return b.date.localeCompare(a.date);
|
||||
if (a.date && !b.date) return -1;
|
||||
if (!a.date && b.date) return 1;
|
||||
return b.modifiedAt.localeCompare(a.modifiedAt) || a.path.localeCompare(b.path);
|
||||
}
|
||||
|
||||
function pick<K extends string>(key: K, value: string | undefined): Partial<Record<K, string>> {
|
||||
return value ? ({ [key]: value } as Record<K, string>) : {};
|
||||
}
|
||||
|
||||
function headingTitle(body: string): string | undefined {
|
||||
const match = /^\s*#\s+(.+)$/m.exec(body);
|
||||
return match?.[1]?.trim();
|
||||
}
|
||||
|
||||
function titleFromFileName(fileName: string): string {
|
||||
const withoutExtension = fileName.replace(/\.(md|markdown|txt)$/i, '');
|
||||
return withoutExtension.replace(/^\d{4}-\d{2}-\d{2}[ _-]*/, '').trim() || withoutExtension;
|
||||
}
|
||||
|
||||
function dateFromFileName(fileName: string): string | undefined {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(fileName);
|
||||
return match ? `${match[1]}-${match[2]}-${match[3]}` : undefined;
|
||||
}
|
||||
|
||||
/** The first folder is the subject, by the layout `notePathFor` writes. */
|
||||
function subjectFromPath(relative: string): string | undefined {
|
||||
const parts = relative.split('/');
|
||||
return parts.length > 1 ? parts[0] : undefined;
|
||||
}
|
||||
|
||||
/** `15.09.2026` and `2026-09-15T08:00:00Z` both mean the same school day. */
|
||||
function normalizeDate(value: string): string | undefined {
|
||||
const german = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(value);
|
||||
if (german) return `${german[3]}-${german[2]!.padStart(2, '0')}-${german[1]!.padStart(2, '0')}`;
|
||||
const iso = /^(\d{4}-\d{2}-\d{2})/.exec(value);
|
||||
return iso ? iso[1] : undefined;
|
||||
}
|
||||
|
||||
function parseList(value: string): string[] {
|
||||
const inner = /^\[(.*)\]$/.exec(value)?.[1] ?? value;
|
||||
return inner
|
||||
.split(',')
|
||||
.map((entry) => unquote(entry.trim()))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function unquote(value: string): string {
|
||||
const match = /^(['"])(.*)\1$/.exec(value);
|
||||
return match ? match[2]! : value;
|
||||
}
|
||||
|
||||
/** Quotes only when the value would otherwise change meaning on the way back in. */
|
||||
function quote(value: string): string {
|
||||
const clean = value.replace(/[\r\n]+/g, ' ').trim();
|
||||
return /^[\w äöüÄÖÜß.,/()+-]+$/.test(clean) && !/^\[/.test(clean) ? clean : JSON.stringify(clean);
|
||||
}
|
||||
178
src/core/untis-history.ts
Normal file
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;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type FsErrorCode,
|
||||
type WalkEntry,
|
||||
} from '../core/legacy-files.ts';
|
||||
import { filterNotes, NoteNotFound, readNoteAt, readNotes, writeNote } from '../core/notes.ts';
|
||||
import { resolveWithin } from '../core/paths.ts';
|
||||
import { TokenRejected } from '../core/session-token.ts';
|
||||
import type { Services } from '../services.ts';
|
||||
@@ -28,6 +29,9 @@ import type { Services } from '../services.ts';
|
||||
* index and mirror, `/token` only to the server's own token, and every upstream
|
||||
* call either triggers is a GET.
|
||||
*/
|
||||
const NO_NOTES_DIR =
|
||||
'This server keeps no notes: NOTES_DIR is not set on it. See docs/NOTES.md.';
|
||||
|
||||
export function createApiRouter(services: Services): Router {
|
||||
const router = express.Router();
|
||||
|
||||
@@ -237,6 +241,72 @@ export function createApiRouter(services: Services): Router {
|
||||
// The only upstream call is the GET /me a replacement must pass first. Works
|
||||
// without an index, since a server without one still needs a token.
|
||||
|
||||
// --- the user's own lesson notes ----------------------------------------
|
||||
//
|
||||
// Read off disk, like the fs_* routes read Schulcloud: no index involved, so
|
||||
// these answer before the first crawl and while Postgres is down. The POST is
|
||||
// the only write in this server that is not the index or its own token, and
|
||||
// it can reach nothing but the notes directory — `writeNote` builds every
|
||||
// path component with `safeComponent` and checks the result with
|
||||
// `resolveWithin`.
|
||||
|
||||
router.get('/notes', async (req: Request, res: Response) => {
|
||||
const root = services.config.notesDir;
|
||||
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });
|
||||
try {
|
||||
const path = stringParam(req.query.path);
|
||||
if (path) return res.json(await readNoteAt(root, path));
|
||||
const notes = filterNotes(await readNotes(root), {
|
||||
...pickParam('subject', req.query.subject),
|
||||
...pickParam('since', req.query.since),
|
||||
...pickParam('until', req.query.until),
|
||||
...pickParam('courseId', req.query.courseId),
|
||||
});
|
||||
const limit = Math.min(Number.parseInt(stringParam(req.query.limit) ?? '', 10) || 500, 2000);
|
||||
return res.json({
|
||||
root,
|
||||
writable: services.config.notesWritable,
|
||||
count: notes.length,
|
||||
// The body is dropped from a listing: a term of notes is megabytes,
|
||||
// and the CLI asks for the ones it wants by path.
|
||||
notes: notes.slice(0, limit).map(({ text, ...rest }) => rest),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof NoteNotFound) return res.status(404).json({ error: 'not_found', message: error.message });
|
||||
return fail(res, error, 'notes');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/notes', express.json({ limit: '1mb' }), async (req: Request, res: Response) => {
|
||||
const root = services.config.notesDir;
|
||||
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });
|
||||
if (!services.config.notesWritable) {
|
||||
return res.status(403).json({ error: 'notes_readonly', message: 'This server was started with NOTES_READONLY.' });
|
||||
}
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const title = typeof body.title === 'string' ? body.title.trim() : '';
|
||||
const noteText = typeof body.text === 'string' ? body.text : '';
|
||||
if (!title || !noteText.trim()) {
|
||||
return res.status(400).json({ error: 'invalid', message: 'A note needs a title and some text.' });
|
||||
}
|
||||
try {
|
||||
const { note, appended } = await writeNote(root, {
|
||||
title,
|
||||
text: noteText,
|
||||
...pickParam('date', body.date),
|
||||
...pickParam('subject', body.subject),
|
||||
...pickParam('courseId', body.courseId),
|
||||
...pickParam('path', body.path),
|
||||
...pickParam('source', body.source),
|
||||
...(Array.isArray(body.tags) ? { tags: body.tags.filter((tag): tag is string => typeof tag === 'string') } : {}),
|
||||
append: body.append === true,
|
||||
});
|
||||
return res.status(appended ? 200 : 201).json({ ...note, appended });
|
||||
} catch (error) {
|
||||
return fail(res, error, 'save a note');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/token', (_req: Request, res: Response) => {
|
||||
res.json(tokenStatus(services));
|
||||
});
|
||||
@@ -352,6 +422,16 @@ function stringParam(value: unknown): string | undefined {
|
||||
return typeof first === 'string' && first.length > 0 ? first : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A query or body value as an optional field, so callers can spread it into an
|
||||
* options object without turning "not given" into `undefined` the way an
|
||||
* exactOptionalPropertyTypes build rejects.
|
||||
*/
|
||||
function pickParam<K extends string>(key: K, value: unknown): Partial<Record<K, string>> {
|
||||
const text = stringParam(value);
|
||||
return text ? ({ [key]: text } as Record<K, string>) : {};
|
||||
}
|
||||
|
||||
function boundedInt(value: unknown, fallback: number, min: number, max: number): number {
|
||||
const parsed = Number.parseInt(stringParam(value) ?? '', 10);
|
||||
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
253
src/mcp/tools/notes.ts
Normal file
253
src/mcp/tools/notes.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import type { ServerContext } from '../../context.ts';
|
||||
import { germanDay, isCalendarDate, schoolToday } from '../../core/dates.ts';
|
||||
import {
|
||||
filterNotes,
|
||||
NoteNotFound,
|
||||
readNoteAt,
|
||||
readNotes,
|
||||
writeNote,
|
||||
type NoteDoc,
|
||||
} from '../../core/notes.ts';
|
||||
import { heading, joinSections, matchesAll, tokenize } from '../../core/text.ts';
|
||||
import { failure, text, toToolError } from './result.ts';
|
||||
|
||||
/**
|
||||
* The user's own lesson notes.
|
||||
*
|
||||
* Registered only when NOTES_DIR is set, on the same principle as the untis_*
|
||||
* tools: a note tool with nowhere to read from can only ever fail, and a model
|
||||
* offered one will keep trying it.
|
||||
*
|
||||
* These are the only tools in this server that write anything, and what they
|
||||
* write is the user's own notes directory — never Schulcloud, which stays
|
||||
* read-only in the strict sense the invariant in CLAUDE.md describes. The write
|
||||
* is bounded by the same two functions the file mirror uses: every path
|
||||
* component is reduced by `safeComponent` and the result is checked by
|
||||
* `resolveWithin`, so a title of `../../.ssh/authorized_keys` becomes a
|
||||
* filename and not a path.
|
||||
*/
|
||||
|
||||
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
||||
|
||||
/** Notes listed before the tool starts summarising instead of listing. */
|
||||
const MAX_LISTED = 200;
|
||||
|
||||
const dateArgument = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD.')
|
||||
.describe('A date as YYYY-MM-DD.');
|
||||
|
||||
export function registerNoteTools(server: McpServer, context: ServerContext): void {
|
||||
const root = context.config.notesDir;
|
||||
if (!root) return;
|
||||
|
||||
server.registerTool(
|
||||
'list_notes',
|
||||
{
|
||||
title: 'My lesson notes',
|
||||
description:
|
||||
"The user's own notes from lessons — what they wrote down themselves, which is neither in Schulcloud " +
|
||||
'nor in WebUntis and is often the only record of what a teacher actually said. **Read these before ' +
|
||||
'answering anything about what was covered in class, and before preparing for a test**: they say what ' +
|
||||
'was emphasised, which the uploaded material does not. Filter by subject ("Deutsch", "LF07") or by ' +
|
||||
'date to get the lessons around a topic. Returns titles and first lines; get_note opens one. ' +
|
||||
'search finds notes by their contents as well.',
|
||||
inputSchema: {
|
||||
subject: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Only notes for this subject, matched as a fragment. The user\'s own wording, not a course id.'),
|
||||
since: dateArgument.optional().describe('Only notes from this day onwards.'),
|
||||
until: dateArgument.optional().describe('Only notes up to and including this day.'),
|
||||
query: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Only notes whose title, subject or tags contain every word given. For full text, use search.'),
|
||||
limit: z.number().int().min(1).max(MAX_LISTED).default(50).describe('Maximum notes to list.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ subject, since, until, query, limit }) => {
|
||||
const unreal = [since, until].filter((value): value is string => Boolean(value) && !isCalendarDate(value!));
|
||||
if (unreal.length > 0) return failure(`Not a date in the calendar: ${unreal.join(', ')}. Use YYYY-MM-DD.`);
|
||||
try {
|
||||
const all = await readNotes(root);
|
||||
if (all.length === 0) return text(emptyStore(root, context.config.notesWritable));
|
||||
|
||||
const terms = query ? tokenize(query) : [];
|
||||
const matched = filterNotes(all, { subject, since, until }).filter(
|
||||
(note) => terms.length === 0 || matchesAll([note.title, note.subject, note.tags.join(' ')].join(' '), terms),
|
||||
);
|
||||
if (matched.length === 0) {
|
||||
return text(
|
||||
`None of the ${all.length} note(s) match${describeFilter({ subject, since, until, query })}. ` +
|
||||
'Drop a filter, or use search to look inside the text.',
|
||||
);
|
||||
}
|
||||
return text(
|
||||
joinSections([
|
||||
heading(2, `Notizen (${Math.min(limit, matched.length)} of ${matched.length})`),
|
||||
matched.slice(0, limit).map(listLine).join('\n'),
|
||||
matched.length > limit ? `_${matched.length - limit} more — narrow it down with subject or since._` : undefined,
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
return toToolError(error, 'read the notes directory');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_note',
|
||||
{
|
||||
title: 'Read one note',
|
||||
description:
|
||||
'The full text of one of the user\'s own notes, by the path list_notes and search print. Quote from it ' +
|
||||
'the way you would quote a course file — it is a primary source for what happened in the lesson.',
|
||||
inputSchema: {
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('The note\'s path, e.g. "Deutsch/2026-09-15 Erörterung.md", exactly as it was listed.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ path }) => {
|
||||
try {
|
||||
return text(renderNote(await readNoteAt(root, path)));
|
||||
} catch (error) {
|
||||
if (error instanceof NoteNotFound) {
|
||||
return failure(
|
||||
`There is no note at "${path}". Paths come from list_notes or search and include the folder and ` +
|
||||
'the .md ending.',
|
||||
);
|
||||
}
|
||||
return toToolError(error, `read the note "${path}"`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!context.config.notesWritable) return;
|
||||
|
||||
server.registerTool(
|
||||
'add_note',
|
||||
{
|
||||
title: 'Write a lesson note',
|
||||
description:
|
||||
'Saves a note into the user\'s own notes, so it is there next time — during a lesson ("halte fest, ' +
|
||||
'dass …"), or when writing up what was just discussed. Give the subject as the user says it ' +
|
||||
'("Deutsch", "LF07") and the day the lesson was on; both are what makes the note findable later. ' +
|
||||
'Pass append=true to add to the note already written for that subject and day rather than starting a ' +
|
||||
'second one — that is the right choice during a lesson. This writes **only** to the notes directory; ' +
|
||||
'it cannot change anything in Schulcloud or WebUntis. Do not use it to store things the user did not ' +
|
||||
'ask to keep.',
|
||||
inputSchema: {
|
||||
title: z.string().min(1).max(200).describe('A short title — the topic of the lesson, not a sentence.'),
|
||||
text: z.string().min(1).describe('The note itself, as Markdown. Write it in the language the user used.'),
|
||||
subject: z
|
||||
.string()
|
||||
.max(80)
|
||||
.optional()
|
||||
.describe('Subject as the user names it, e.g. "Deutsch" or "LF07". Becomes the folder.'),
|
||||
date: dateArgument.optional().describe('The day of the lesson. Defaults to today.'),
|
||||
tags: z.array(z.string().max(40)).max(12).optional().describe('Optional keywords, e.g. ["klausur"].'),
|
||||
courseId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Schulcloud course id, when it is known — it links the note to the course in search.'),
|
||||
append: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe('Add to an existing note for that subject and day instead of creating another one.'),
|
||||
},
|
||||
// Writes — to the notes directory, and to nothing else.
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
},
|
||||
async ({ title, text: body, subject, date, tags, courseId, append }) => {
|
||||
if (date && !isCalendarDate(date)) return failure(`Not a date in the calendar: ${date}. Use YYYY-MM-DD.`);
|
||||
try {
|
||||
const { note, appended } = await writeNote(root, {
|
||||
title,
|
||||
text: body,
|
||||
date: date ?? schoolToday(),
|
||||
...(subject ? { subject } : {}),
|
||||
...(courseId ? { courseId } : {}),
|
||||
...(tags && tags.length > 0 ? { tags } : {}),
|
||||
source: 'add_note',
|
||||
append,
|
||||
});
|
||||
return text(
|
||||
joinSections([
|
||||
`${appended ? 'Added to' : 'Saved'} **${note.title}** — \`${note.path}\``,
|
||||
'_It is searchable after the next refresh_index; get_note reads it now._',
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
return toToolError(error, `save the note "${title}"`);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// --- formatting ----------------------------------------------------------
|
||||
|
||||
function listLine(note: NoteDoc): string {
|
||||
const when = note.date ? germanDay(note.date) : 'ohne Datum';
|
||||
const where = note.subject ? ` · ${note.subject}` : '';
|
||||
const tags = note.tags.length > 0 ? ` · ${note.tags.map((tag) => `#${tag}`).join(' ')}` : '';
|
||||
const first = firstLine(note.text);
|
||||
return [`- **${note.title}** — ${when}${where}${tags} \`${note.path}\``, first ? ` ${first}` : undefined]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function renderNote(note: NoteDoc): string {
|
||||
const facts = [
|
||||
note.date ? `Datum: ${germanDay(note.date)}` : undefined,
|
||||
note.subject ? `Fach: ${note.subject}` : undefined,
|
||||
note.tags.length > 0 ? `Tags: ${note.tags.join(', ')}` : undefined,
|
||||
note.courseId ? `Kurs: \`${note.courseId}\`` : undefined,
|
||||
].filter(Boolean);
|
||||
return joinSections([
|
||||
heading(2, note.title),
|
||||
facts.length > 0 ? `_${facts.join(' · ')}_` : undefined,
|
||||
note.text || '_This note is empty._',
|
||||
`_Own note: \`${note.path}\`_`,
|
||||
]);
|
||||
}
|
||||
|
||||
function firstLine(body: string): string | undefined {
|
||||
const line = body
|
||||
.split('\n')
|
||||
.map((entry) => entry.replace(/^#+\s*/, '').trim())
|
||||
.find((entry) => entry.length > 0);
|
||||
if (!line) return undefined;
|
||||
return line.length > 160 ? `${line.slice(0, 157)}…` : line;
|
||||
}
|
||||
|
||||
function describeFilter(filter: { subject?: string; since?: string; until?: string; query?: string }): string {
|
||||
const parts = [
|
||||
filter.subject ? `subject "${filter.subject}"` : undefined,
|
||||
filter.query ? `"${filter.query}"` : undefined,
|
||||
filter.since ? `from ${germanDay(filter.since)}` : undefined,
|
||||
filter.until ? `to ${germanDay(filter.until)}` : undefined,
|
||||
].filter(Boolean);
|
||||
return parts.length > 0 ? ` ${parts.join(', ')}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The empty case, which is the normal one on a fresh install.
|
||||
*
|
||||
* It says where the directory is because the usual next step is to put files
|
||||
* there by hand or with the import script, not to call a tool.
|
||||
*/
|
||||
function emptyStore(root: string, writable: boolean): string {
|
||||
return joinSections([
|
||||
`There are no notes yet. The notes directory is \`${root}\`.`,
|
||||
writable
|
||||
? 'Notes are Markdown files; add_note writes one, and anything dropped in that directory is picked up too.'
|
||||
: 'This server was started with NOTES_READONLY, so notes have to be put there by hand or synced in.',
|
||||
]);
|
||||
}
|
||||
@@ -19,6 +19,10 @@ const TOOL_FOR: Record<string, string> = {
|
||||
// A submission is reached through its task, not by an id of its own: there
|
||||
// is no get_submission because the API has no route to one.
|
||||
submission: 'get_task',
|
||||
note: 'get_note',
|
||||
// A class-register hit is followed up by its series, not by the single
|
||||
// period: untis_lesson_topics with that periodId returns the lessons around it.
|
||||
untis: 'untis_lesson_topics',
|
||||
};
|
||||
|
||||
export function registerSearchTool(server: McpServer, context: ServerContext): void {
|
||||
@@ -31,14 +35,20 @@ export function registerSearchTool(server: McpServer, context: ServerContext): v
|
||||
'names — and, unlike anything else here, **the text inside PDFs, Word, PowerPoint and OpenDocument ' +
|
||||
'files**. Use it whenever the user names a topic rather than a course ("where is the stuff about ' +
|
||||
'encryption?"). Matching is case- and accent-insensitive and understands German word forms. ' +
|
||||
'It covers three sources at once: the Schulcloud material, **the user\'s own lesson notes** and ' +
|
||||
'**the WebUntis class register** — so one query answers "what do we have on this, what did I write ' +
|
||||
'down, and when did we do it". Restrict with kinds to just one of them. ' +
|
||||
'Results come from a local index; if they look stale, refresh_index re-reads Schulcloud.',
|
||||
inputSchema: {
|
||||
query: z.string().min(2).describe('What to look for. German and English both work.'),
|
||||
courseId: z.string().optional().describe('Restrict the search to a single course.'),
|
||||
kinds: z
|
||||
.array(z.enum(['course', 'room', 'board', 'lesson', 'task', 'file']))
|
||||
.array(z.enum(['course', 'room', 'board', 'lesson', 'task', 'file', 'submission', 'note', 'untis']))
|
||||
.optional()
|
||||
.describe('Restrict to certain kinds of thing, e.g. ["file"] to find documents only.'),
|
||||
.describe(
|
||||
'Restrict to certain kinds of thing: ["file"] for documents only, ["note"] for the user\'s own ' +
|
||||
'notes, ["untis"] for what the class register says was taught.',
|
||||
),
|
||||
limit: z.number().int().min(1).max(100).default(30).describe('Maximum number of hits to return.'),
|
||||
fresh: z
|
||||
.boolean()
|
||||
@@ -117,6 +127,9 @@ async function liveSearch(
|
||||
// Same trade as files: worth two extra requests per pad when the caller
|
||||
// named a course, too slow to do across every course they can see.
|
||||
config: scoped ? context.config : undefined,
|
||||
// Notes are local files, so the live path can afford them and must: a
|
||||
// fresh search that silently dropped them would disagree with the index.
|
||||
...(context.config.notesDir ? { notesDir: context.config.notesDir } : {}),
|
||||
});
|
||||
const hits = searchSnapshot(snapshot, query, limit);
|
||||
|
||||
@@ -153,6 +166,12 @@ function nextStep(hit: SearchResult): string {
|
||||
? ` → \`fs_read\` with path \`${fsPath}\``
|
||||
: ` → \`fs_read\` with fileId \`${hit.nodeId}\` and name \`${hit.title}\``;
|
||||
}
|
||||
// A note is addressed by its path, not by an id.
|
||||
if (hit.kind === 'note') return ` → \`get_note\` with path \`${hit.nodeId}\``;
|
||||
if (hit.kind === 'untis') {
|
||||
const periodId = hit.meta?.periodId;
|
||||
return ` → \`untis_lesson_topics\` with periodId \`${typeof periodId === 'number' ? periodId : hit.nodeId}\``;
|
||||
}
|
||||
// A submission has no id of its own that any tool takes: get_task is
|
||||
// reached through the *task*, so point at that rather than at the
|
||||
// submission id, which would simply 404.
|
||||
@@ -167,11 +186,25 @@ function fileManagerPlace(hit: SearchResult): string {
|
||||
return known.area === 'courses' && hit.courseTitle ? `${known.label}, ${hit.courseTitle}` : known.label;
|
||||
}
|
||||
|
||||
/** What kind of thing a hit is, in words rather than in the store's vocabulary. */
|
||||
function placeOf(hit: SearchResult): string {
|
||||
if (hit.kind === 'file' && hit.meta?.source === 'file-manager') return `file in ${fileManagerPlace(hit)}`;
|
||||
if (hit.kind === 'note') {
|
||||
const date = typeof hit.meta?.date === 'string' ? formatDate(hit.meta.date) : undefined;
|
||||
const subject = typeof hit.meta?.subject === 'string' ? hit.meta.subject : undefined;
|
||||
// Named as the user's own writing, so it is never quoted as if the school
|
||||
// had published it.
|
||||
return ['my own note', subject, date].filter(Boolean).join(', ');
|
||||
}
|
||||
if (hit.kind === 'untis') {
|
||||
const date = typeof hit.meta?.date === 'string' ? formatDate(hit.meta.date) : undefined;
|
||||
return ['class register (WebUntis)', date].filter(Boolean).join(', ');
|
||||
}
|
||||
return `${hit.kind} in ${hit.courseTitle || hit.path}`;
|
||||
}
|
||||
|
||||
function formatIndexed(hit: SearchResult): string {
|
||||
const where =
|
||||
hit.kind === 'file' && hit.meta?.source === 'file-manager'
|
||||
? `file in ${fileManagerPlace(hit)}`
|
||||
: `${hit.kind} in ${hit.courseTitle || hit.path}`;
|
||||
const where = placeOf(hit);
|
||||
return [
|
||||
`- **${hit.title}** — ${where}`,
|
||||
hit.snippet && hit.snippet !== hit.title ? ` ${hit.snippet}` : undefined,
|
||||
@@ -182,11 +215,12 @@ function formatIndexed(hit: SearchResult): string {
|
||||
}
|
||||
|
||||
function formatLive(hit: Hit): string {
|
||||
return [
|
||||
`- **${hit.courseTitle}** — ${hit.where}`,
|
||||
` ${hit.snippet}`,
|
||||
` → \`${TOOL_FOR[hit.targetKind]}\` with id \`${hit.targetId}\``,
|
||||
].join('\n');
|
||||
// A note is addressed by path; everything else by id.
|
||||
const next =
|
||||
hit.targetKind === 'note'
|
||||
? ` → \`get_note\` with path \`${hit.targetId}\``
|
||||
: ` → \`${TOOL_FOR[hit.targetKind]}\` with id \`${hit.targetId}\``;
|
||||
return [`- **${hit.courseTitle}** — ${hit.where}`, ` ${hit.snippet}`, next].join('\n');
|
||||
}
|
||||
|
||||
function freshness(crawledAt: string | undefined): string {
|
||||
|
||||
@@ -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 } from '../core/notes.ts';
|
||||
import { mirrorPath } from '../core/paths.ts';
|
||||
import { lessonLogText } from '../core/untis-history.ts';
|
||||
import { connect, migrate, type Db } from './db.ts';
|
||||
|
||||
/**
|
||||
@@ -14,7 +16,23 @@ import { connect, migrate, type Db } from './db.ts';
|
||||
* Identity diffing also gives deletions for free, which no timestamp scheme can.
|
||||
*/
|
||||
|
||||
export type NodeKind = 'course' | 'room' | 'board' | 'lesson' | 'task' | 'file' | 'submission';
|
||||
/**
|
||||
* `note` and `untis` are not Schulcloud's: the first is what the user wrote
|
||||
* down, the second is the WebUntis class register. They live in the same table
|
||||
* because the question they answer is the same one — "where is the material
|
||||
* about X" — and a search that made the user choose which of three systems to
|
||||
* look in would be answering a question nobody asked.
|
||||
*/
|
||||
export type NodeKind =
|
||||
| 'course'
|
||||
| 'room'
|
||||
| 'board'
|
||||
| 'lesson'
|
||||
| 'task'
|
||||
| 'file'
|
||||
| 'submission'
|
||||
| 'note'
|
||||
| 'untis';
|
||||
|
||||
export interface StoredNode {
|
||||
kind: NodeKind;
|
||||
@@ -632,6 +650,57 @@ export function snapshotToNodes(snapshot: Snapshot): StoredNode[] {
|
||||
}
|
||||
}
|
||||
|
||||
// The user's own notes. `courseId` is set only when the note names one, so
|
||||
// most notes sit outside any course — which is also what makes them survive
|
||||
// a per-course crawl's carry-forward untouched.
|
||||
for (const note of snapshot.notes ?? []) {
|
||||
nodes.push({
|
||||
kind: 'note',
|
||||
nodeId: note.path,
|
||||
courseId: note.courseId ?? null,
|
||||
title: note.title,
|
||||
body: noteSearchText(note),
|
||||
path: `Notizen/${note.path}`,
|
||||
meta: {
|
||||
...(note.date ? { date: note.date } : {}),
|
||||
...(note.subject ? { subject: note.subject } : {}),
|
||||
...(note.source ? { source: note.source } : {}),
|
||||
tags: note.tags,
|
||||
modifiedAt: note.modifiedAt,
|
||||
bytes: note.bytes,
|
||||
},
|
||||
// The file's mtime is deliberately not in the digest: a sync tool that
|
||||
// rewrites a file byte-for-byte must not show up as a changed note.
|
||||
digest: digestOf([note.title, note.text, note.subject ?? '', note.date ?? '', note.tags]),
|
||||
});
|
||||
}
|
||||
|
||||
// The class register. The period id is the identity: it is stable, and it is
|
||||
// what untis_lesson_topics takes, so a search hit can be followed up.
|
||||
for (const entry of snapshot.lessonLog ?? []) {
|
||||
const subject = entry.subject ?? 'Unterricht';
|
||||
nodes.push({
|
||||
kind: 'untis',
|
||||
nodeId: `period-${entry.periodId}`,
|
||||
courseId: null,
|
||||
title: `${subject} — ${entry.date}`,
|
||||
body: lessonLogText(entry),
|
||||
path: `Klassenbuch/${subject}/${entry.date}`,
|
||||
meta: {
|
||||
periodId: entry.periodId,
|
||||
lessonId: entry.lessonId,
|
||||
date: entry.date,
|
||||
start: entry.start,
|
||||
end: entry.end,
|
||||
...(entry.subject ? { subject: entry.subject } : {}),
|
||||
...(entry.subjectLong ? { subjectLong: entry.subjectLong } : {}),
|
||||
teachers: entry.teachers,
|
||||
...(entry.exam ? { exam: entry.exam } : {}),
|
||||
},
|
||||
digest: digestOf([entry.topic ?? '', entry.notes, entry.exam ?? '', entry.homework]),
|
||||
});
|
||||
}
|
||||
|
||||
for (const file of snapshot.files) {
|
||||
nodes.push(fileNode(file));
|
||||
}
|
||||
|
||||
100
test/apple-notes.test.ts
Normal file
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/);
|
||||
});
|
||||
});
|
||||
221
test/notes.test.ts
Normal file
221
test/notes.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, writeFile, readFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
filterNotes,
|
||||
NoteNotFound,
|
||||
notePathFor,
|
||||
parseNote,
|
||||
readNoteAt,
|
||||
readNotes,
|
||||
renderNote,
|
||||
splitFrontmatter,
|
||||
writeNote,
|
||||
} from '../src/core/notes.ts';
|
||||
|
||||
const STAMP = { modifiedAt: '2026-09-15T10:00:00.000Z', bytes: 100 };
|
||||
|
||||
async function root(): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), 'schulcloud-notes-'));
|
||||
}
|
||||
|
||||
describe('splitFrontmatter', () => {
|
||||
it('reads a leading block and keeps the body', () => {
|
||||
const { front, body } = splitFrontmatter('---\ntitle: Erörterung\ndate: 2026-09-15\n---\n\nText hier.\n');
|
||||
assert.equal(front.title, 'Erörterung');
|
||||
assert.equal(front.date, '2026-09-15');
|
||||
assert.equal(body.trim(), 'Text hier.');
|
||||
});
|
||||
|
||||
it('leaves a note that merely starts with a rule alone', () => {
|
||||
// A horizontal rule with no closing fence must not eat the note.
|
||||
const { front, body } = splitFrontmatter('---\nkein Frontmatter, nur ein Strich\n');
|
||||
assert.deepEqual(front, {});
|
||||
assert.match(body, /kein Frontmatter/);
|
||||
});
|
||||
|
||||
it('accepts a note with no frontmatter at all', () => {
|
||||
const { front, body } = splitFrontmatter('# Titel\n\nText.');
|
||||
assert.deepEqual(front, {});
|
||||
assert.equal(body, '# Titel\n\nText.');
|
||||
});
|
||||
|
||||
it('takes a German date and an inline tag list', () => {
|
||||
const { front } = splitFrontmatter('---\ndate: 15.09.2026\ntags: [klausur, "aufsatz"]\nfach: Deutsch\n---\nx');
|
||||
assert.equal(front.date, '2026-09-15');
|
||||
assert.deepEqual(front.tags, ['klausur', 'aufsatz']);
|
||||
// "fach" is the German spelling of subject and has to mean the same thing.
|
||||
assert.equal(front.subject, 'Deutsch');
|
||||
});
|
||||
|
||||
it('keeps unknown keys rather than dropping them', () => {
|
||||
const { front } = splitFrontmatter('---\ntitle: T\nlehrer: Frau Meier\n---\nx');
|
||||
assert.equal(front.extra?.lehrer, 'Frau Meier');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseNote', () => {
|
||||
it('falls back to the heading, then to the filename, for a title', () => {
|
||||
assert.equal(parseNote('a.md', '# Kryptografie\n\nText', STAMP).title, 'Kryptografie');
|
||||
assert.equal(parseNote('Deutsch/2026-09-15 Erörterung.md', 'nur Text', STAMP).title, 'Erörterung');
|
||||
});
|
||||
|
||||
it('takes the date from the filename when the frontmatter has none', () => {
|
||||
assert.equal(parseNote('Deutsch/2026-09-15 Erörterung.md', 'x', STAMP).date, '2026-09-15');
|
||||
});
|
||||
|
||||
it('never dates a note from its mtime', () => {
|
||||
// An import writes every file today; dating a year of lessons "today"
|
||||
// would make the whole store useless for revision.
|
||||
assert.equal(parseNote('lose Notiz.md', 'x', STAMP).date, undefined);
|
||||
});
|
||||
|
||||
it('takes the folder as the subject', () => {
|
||||
assert.equal(parseNote('LF07/2026-09-15 Netze.md', 'x', STAMP).subject, 'LF07');
|
||||
assert.equal(parseNote('lose.md', 'x', STAMP).subject, undefined);
|
||||
});
|
||||
|
||||
it('round-trips through renderNote', () => {
|
||||
const rendered = renderNote({ title: 'Erörterung', date: '2026-09-15', subject: 'Deutsch', tags: ['klausur'] }, 'Body');
|
||||
const note = parseNote('Deutsch/x.md', rendered, STAMP);
|
||||
assert.equal(note.title, 'Erörterung');
|
||||
assert.equal(note.date, '2026-09-15');
|
||||
assert.equal(note.subject, 'Deutsch');
|
||||
assert.deepEqual(note.tags, ['klausur']);
|
||||
assert.equal(note.text, 'Body');
|
||||
});
|
||||
});
|
||||
|
||||
describe('notePathFor', () => {
|
||||
it('is subject then date then title', () => {
|
||||
assert.equal(notePathFor({ date: '2026-09-15', subject: 'Deutsch', title: 'Erörterung' }), 'Deutsch/2026-09-15 Erörterung.md');
|
||||
});
|
||||
|
||||
it('reduces a hostile title to one component', () => {
|
||||
// The title comes from a tool call, so it is untrusted input that becomes
|
||||
// a filename — the same boundary the file mirror has.
|
||||
const path = notePathFor({ date: '2026-09-15', subject: '../../etc', title: '../../.ssh/authorized_keys' });
|
||||
assert.equal(path.split('/').length, 2);
|
||||
assert.ok(!path.includes('..'), path);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readNotes', () => {
|
||||
it('is empty, not an error, for a directory that does not exist', async () => {
|
||||
assert.deepEqual(await readNotes(join(tmpdir(), 'schulcloud-notes-absent-xyz')), []);
|
||||
});
|
||||
|
||||
it('walks folders, skips dotfiles and non-notes, and sorts newest first', async () => {
|
||||
const dir = await root();
|
||||
await mkdir(join(dir, 'Deutsch'), { recursive: true });
|
||||
await mkdir(join(dir, '.obsidian'), { recursive: true });
|
||||
await writeFile(join(dir, 'Deutsch', '2026-09-15 Erörterung.md'), 'A');
|
||||
await writeFile(join(dir, 'Deutsch', '2026-09-22 Analyse.md'), 'B');
|
||||
await writeFile(join(dir, '.obsidian', 'workspace.md'), 'nope');
|
||||
await writeFile(join(dir, 'bild.png'), 'nope');
|
||||
|
||||
const notes = await readNotes(dir);
|
||||
assert.deepEqual(notes.map((note) => note.title), ['Analyse', 'Erörterung']);
|
||||
});
|
||||
|
||||
it('refuses to read its way out of the root', async () => {
|
||||
const dir = await root();
|
||||
await assert.rejects(() => readNoteAt(dir, '../../etc/passwd'), /traversal/);
|
||||
});
|
||||
|
||||
it('reports a missing note as missing', async () => {
|
||||
const dir = await root();
|
||||
await assert.rejects(() => readNoteAt(dir, 'Deutsch/nichts.md'), NoteNotFound);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeNote', () => {
|
||||
it('creates a note with frontmatter at the derived path', async () => {
|
||||
const dir = await root();
|
||||
const { note } = await writeNote(dir, { title: 'Erörterung', text: 'Aufbau: These, Argument, Fazit.', subject: 'Deutsch', date: '2026-09-15' });
|
||||
assert.equal(note.path, 'Deutsch/2026-09-15 Erörterung.md');
|
||||
assert.equal(note.subject, 'Deutsch');
|
||||
assert.match(await readFile(join(dir, note.path), 'utf8'), /^---\ntitle: Erörterung\n/);
|
||||
});
|
||||
|
||||
it('appends to the same file when asked, so a lesson stays one note', async () => {
|
||||
const dir = await root();
|
||||
await writeNote(dir, { title: 'Erörterung', text: 'Erstens.', subject: 'Deutsch', date: '2026-09-15' });
|
||||
const { note, appended } = await writeNote(dir, {
|
||||
title: 'Nachtrag', text: 'Zweitens.', subject: 'Deutsch', date: '2026-09-15',
|
||||
path: 'Deutsch/2026-09-15 Erörterung.md', append: true,
|
||||
});
|
||||
assert.equal(appended, true);
|
||||
assert.match(note.text, /Erstens\./);
|
||||
assert.match(note.text, /Zweitens\./);
|
||||
assert.equal((await readNotes(dir)).length, 1);
|
||||
});
|
||||
|
||||
it('appends by lesson, not by title — a second note in the same lesson has another name', async () => {
|
||||
// "halt das auch noch fest" mid-lesson carries a new title; deriving the
|
||||
// path from it would start a second note every time, which is the one
|
||||
// thing append exists to prevent.
|
||||
const dir = await root();
|
||||
await writeNote(dir, { title: 'Erörterung', text: 'Erstens.', subject: 'Deutsch', date: '2026-09-15' });
|
||||
const { note, appended } = await writeNote(dir, {
|
||||
title: 'Nachtrag', text: 'Zweitens.', subject: 'Deutsch', date: '2026-09-15', append: true,
|
||||
});
|
||||
assert.equal(appended, true);
|
||||
assert.equal(note.path, 'Deutsch/2026-09-15 Erörterung.md');
|
||||
assert.equal((await readNotes(dir)).length, 1);
|
||||
});
|
||||
|
||||
it('creates the note when append finds nothing to append to', async () => {
|
||||
const dir = await root();
|
||||
const { note, appended } = await writeNote(dir, { title: 'Erstes', text: 'x', subject: 'Deutsch', date: '2026-09-15', append: true });
|
||||
assert.equal(appended, false);
|
||||
assert.equal(note.path, 'Deutsch/2026-09-15 Erstes.md');
|
||||
});
|
||||
|
||||
it('does not append across days or subjects', async () => {
|
||||
const dir = await root();
|
||||
await writeNote(dir, { title: 'Montag', text: 'a', subject: 'Deutsch', date: '2026-09-15' });
|
||||
const otherDay = await writeNote(dir, { title: 'Dienstag', text: 'b', subject: 'Deutsch', date: '2026-09-16', append: true });
|
||||
const otherSubject = await writeNote(dir, { title: 'Netze', text: 'c', subject: 'LF07', date: '2026-09-15', append: true });
|
||||
assert.equal(otherDay.appended, false);
|
||||
assert.equal(otherSubject.appended, false);
|
||||
assert.equal((await readNotes(dir)).length, 3);
|
||||
});
|
||||
|
||||
it('never overwrites: a second note of the same name gets its own file', async () => {
|
||||
const dir = await root();
|
||||
await writeNote(dir, { title: 'Test', text: 'eins', subject: 'Deutsch', date: '2026-09-15' });
|
||||
const { note, appended } = await writeNote(dir, { title: 'Test', text: 'zwei', subject: 'Deutsch', date: '2026-09-15' });
|
||||
assert.equal(appended, false);
|
||||
assert.equal(note.path, 'Deutsch/2026-09-15 Test 2.md');
|
||||
assert.equal((await readNotes(dir)).length, 2);
|
||||
});
|
||||
|
||||
it('cannot be steered out of the notes root by its title', async () => {
|
||||
const dir = await root();
|
||||
const { note } = await writeNote(dir, { title: '../../escape', text: 'x', subject: '..', date: '2026-09-15' });
|
||||
assert.ok(!note.path.includes('..'), note.path);
|
||||
assert.equal((await readNotes(dir)).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterNotes', () => {
|
||||
const notes = [
|
||||
parseNote('Deutsch/2026-09-15 A.md', 'a', STAMP),
|
||||
parseNote('LF07/2026-09-22 B.md', 'b', STAMP),
|
||||
parseNote('lose.md', 'c', STAMP),
|
||||
];
|
||||
|
||||
it('matches a subject as a fragment', () => {
|
||||
assert.deepEqual(filterNotes(notes, { subject: 'deut' }).map((note) => note.title), ['A']);
|
||||
});
|
||||
|
||||
it('keeps undated notes inside a date window rather than hiding them', () => {
|
||||
// Excluding them would silently drop every note that arrived without a
|
||||
// date, which is most of an Apple Notes import.
|
||||
const titles = filterNotes(notes, { since: '2026-09-20' }).map((note) => note.title);
|
||||
assert.deepEqual(titles, ['B', 'lose']);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user