29 Commits

Author SHA1 Message Date
MechaCat02
f545b7cf54 Export Apple Notes to stdout, and one folder at a time
`console.log` in JXA writes to standard error, not standard output, so
`> notes.ndjson` produced an empty file while every note scrolled past
on the terminal. Both streams now go through NSFileHandle, which is the
only way to be sure which one you are on; the comment says so, because
the next person will reach for console.log too.

`--folder` was a substring match applied after each note's body had
already been read. It now matches a whole path segment — `Schule` takes
Schule and everything under it and leaves Musikschule alone — and is
checked before the body, which is the one expensive property and is what
makes a large library take minutes.

The path is recorded relative to the folder asked for, because the
import reads its last segment as the subject: `Schule/Deutsch` has to
arrive as `Deutsch`, and a note loose in `Schule` has to arrive with no
subject at all rather than one called "Schule".

Not run: this needs a Mac with Notes, and there is none here. The JXA
stream behaviour is the documented one and the folder logic is plain
string work, but the script itself is still unexercised.
2026-09-20 19:07:20 +02:00
MechaCat02
6173b519db Merge: write the notes as formatted text, not Markdown syntax
The notes app showed a textarea of raw Markdown, which is the wrong
thing to hand someone taking notes during a lesson. It now shows the
note formatted with a toolbar above it, while the file on disk stays
Markdown — that is what the indexer reads and what outlives this app.

The property the whole thing rests on is that the round trip settles:
one pass may tidy a note, a second must change nothing. A note the
editor cannot hold unchanged opens in the Markdown view and says so
rather than being quietly reduced.

Includes the table dead end found on the first real use: a table at the
end of a contenteditable element cannot be typed past, in any engine.
2026-09-20 18:43:08 +02:00
MechaCat02
c259d97f04 Do not let a table be the end of a note
A table, code block, quote or list as the last element of a
contenteditable element is a dead end: there is no node after it to put
the caret in, and no key that makes one, so the note simply cannot be
continued below it. Every engine behaves this way. The editor now keeps
an empty paragraph after such a block — it serializes to nothing, so it
never reaches the file, which a test pins down.

Tables got the rest of what they were missing while the cause was in
view: Tab walks the cells and a Tab out of the last one adds a row, the
toolbar button adds a row when the cursor is already in a table (a phone
has no Tab key, and it renames itself so it says which it will do), and
Ctrl/Cmd+Enter opens a paragraph after whatever block the cursor is in.
Empty cells are given a break, because a `<td></td>` with nothing in it
cannot be clicked into in Gecko — a blank cell was uneditable.

Driven in Firefox against a page that reproduces the dead end first.
2026-09-20 13:06:25 +02:00
MechaCat02
534b1b0f58 Write notes as formatted text, store them as Markdown
The editor was a textarea holding raw Markdown, which is the wrong thing
to hand someone taking notes during a lesson: nobody types `##` and `**`
while a teacher is talking. It now shows the note formatted and puts a
toolbar above it — headings, bold, lists, tick boxes, quotes, links,
tables — while the file on disk stays exactly what it was, because that
is what the indexer reads and what outlives this app.

`markdown.js` is the whole translation: `markdownToHtml` on the way in,
`markdownFromDom` on the way out. The property that matters is that the
round trip settles — one pass may tidy a note, a second must change
nothing — because these notes are the only record of what was said in
the room and there is nothing to restore a lossy save from. `editor.js`
checks exactly that before opening a note formatted, and a note it
cannot hold unchanged opens in the Markdown view and says so instead of
being quietly reduced.

No editor library: the content security policy allows no outside script
and the app has no bundler, so this is `contenteditable` and
`execCommand` with a tolerant serializer behind it — an element it does
not model keeps its words and loses its tag. Pasted HTML is converted to
Markdown before it reaches the document, which is the one place where
sanitising and formatting are the same operation.

Tested against `test/mini-dom.ts`, sixty lines of read-only DOM, rather
than a headless browser or a DOM dependency; the toolbar itself was
driven by hand in Firefox. WebKit has still never run it.
2026-09-19 21:24:42 +02:00
MechaCat02
5c0b658855 Merge: the user's own lesson notes, the class register, and an app to write them in
Three sources instead of two. Schulcloud has the material, WebUntis has
the schedule and now the class register, and the notes have what the
teacher actually stressed — which was the half nothing here could reach.

- core/notes.ts, the notes as Markdown files; a note with ## headings is
  a school day and is indexed per lesson, not whole.
- core/untis-history.ts, the class register read backwards — one call per
  lesson series covers a term, because getLessonTopic2017 answers per
  series rather than per period.
- core/day-note.ts and http/app*, the app at /app: a login, a day editor
  whose headings come from WebUntis, and a settings page for the
  Schulcloud token.
- scripts/export-apple-notes.js and `schulcloud note import`, the way out
  of Notes.app, which has no export of its own.

docs/NOTES.md is the guide, docs/DEPLOY-NOTES.md the rollout runbook.
2026-09-19 17:32:38 +02:00
MechaCat02
ac61aea870 Create /data/notes in the image, and write the rollout runbook
The Dockerfile creates /data/mirror and /data/state with the right owner,
and the comment above it says exactly why: Docker initialises a new named
volume from the image directory, so a mount point the image does not have
lands root-owned and the unprivileged user gets EACCES on every write.
/data/notes was added to docker-compose.yml without being added here, so
every save on a fresh deployment would have failed that way — verified
both directions before fixing it.

docs/DEPLOY-NOTES.md is the runbook for putting this on a server that is
already running: publish, decide where the notes live *before* anything
writes one, set WEB_PASSWORD, verify, migrate, index. Plus rollback,
which is uneventful — no migration, and the old image simply ignores the
new settings and leaves the notes volume alone.

PI.md's backup table needed the bigger change. Everything else this
server stores is a copy of something upstream and a crawl rebuilds it;
the notes are not, and nothing can. They are now the one entry in that
table marked irreplaceable, and the EACCES row says to fix the volume's
ownership rather than delete it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 17:32:30 +02:00
MechaCat02
dc50b4bcd5 Write the notes in an app, a school day at a time
The notes existed but there was nowhere to write them: a CLI command on a
laptop, a tool call through Claude, or a file in a Docker volume. None of
those is reachable from a phone in a lesson, which is where notes are
actually taken.

So: `/app`, served only when WEB_PASSWORD is set. A login, the day's
notes, and a settings page for the Schulcloud token — the one surface
here meant for a person rather than a program.

The shape follows how the notes are written: one note per school day,
one `##` heading per lesson, prose and lists and tables beneath. That
turns out to be the design decision that matters, twice over.

First, it is what lets WebUntis earn its keep. Opening a day with no
note fills in that day's lessons — numbered, with times, teacher and
room, cancellations dropped and substitutions marked. Retyping the
timetable is exactly the work the second upstream exists to avoid, and
"Stunden ergänzen" tops up a note started before the day ended without
touching what is already written.

Second, it changes how notes are indexed. A day note is indexed per
lesson, not whole: search answers "my own note, Deutsch, 18.09.2026"
rather than "my own note, Friday", and `list_notes subject=Deutsch`
finds a day whose frontmatter names no subject at all. Indexed whole,
every hit would read as a weekday and "what did we do in Deutsch" would
match notes whose other five lessons were something else. `lessonHeading`
and `subjectFromHeading` are a loop — the app writes the heading, the
indexer reads the subject back out — and a test holds them to it.

Notes taken in a lesson cannot be retaken, so the editor is built
around not losing them: autosave, every keystroke mirrored to local
storage, a save when the phone locks, and a fallback to the local copy
when the request never arrives. A save that would overwrite a version
the editor never saw is refused and the choice handed back — the notes
folder is synced and open in more than one place, and a phone must not
silently win over a laptop. `replaceNote` is separate from `writeNote`
for that reason: never-overwrite is right for `add_note` and exactly
wrong for an editor.

WEB_PASSWORD is the first credential here a human types, so it is the
first that can be guessed: scrypt at startup, never stored or compared
in the clear, per-address rate limiting — which is not decoration, since
the scrypt cost is itself a denial-of-service vector without it. The
session is a signed HttpOnly SameSite=Strict cookie whose key is derived
from the password, so changing it logs everyone out and there is no
second secret to keep. It opens /api, because a session is the user, and
never /mcp, because nothing in a browser speaks MCP.

Also here, because the app made them matter: frontmatter now reads the
indented `- item` list form editors write, so an Obsidian vault
round-trips its tags; and a four-digit folder is a filing scheme, not a
subject, so `2026/` does not file a school year under one.

357 tests; 106/107 smoke against the local instance, the one failure
being the H5P service that instance does not run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 17:21:17 +02:00
MechaCat02
af4464decb 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>
2026-09-18 21:46:26 +02:00
MechaCat02
ad8ba28313 Document the H5P findings
docs/API.md gains the endpoint and what it returns, why `play` is the bigger
and lesser of the two, the element-by-id route found while probing, and the
per-library shapes with the traps in them — the string "true", the inline cloze
markers, the correct-option-first convention, and the UI subtrees that drown an
exercise in button labels.

CLAUDE.md's "no quiz of its own" note said a contentId was the only handle onto
the content. That was the reason nobody looked further, so it now says where to
look instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:39:19 +02:00
MechaCat02
a0cef532c6 Read the quizzes behind H5P elements
A quiz in Schulcloud is an H5P element, and a board hands over nothing but a
contentId — so a teacher's exercise was until now a line saying one exists.
The player shows a single question at a time, which makes it look like
something to step through or scrape. It is not:
`GET /api/v3/h5p-editor/params/{contentId}` returns the JSON the player is fed,
so one request holds every question, every option and which of them are
correct. (`play/{id}` is the same content plus the player's script lists: 74 kB
against 51 kB for the live quiz. Neither docs-json describes the service.)

get_h5p prints the exercise, and solutions=false keeps the options while
dropping the answers, so it can be used to ask the questions instead of
answering them. get_board names the exercise — title, question count, kinds —
rather than printing a bare id, and the crawl indexes its text, so a phrase
that exists only inside a quiz is now findable. That is the treatment pads
already get, for the same reason: it is course material and nothing else
surfaces it.

What varies is the shape inside `params`, which belongs to whichever H5P
library the teacher used. Modelled: MultiChoice, whose `behaviour.singleAnswer`
is the only honest source for "tick exactly one"; TrueFalse, whose `correct` is
the string "true"; the cloze libraries, which mark solutions inline as
`*answer:tip*`; SingleChoiceSet and Summary, which put the correct option first
and let the player shuffle; and Column. Anything else has its text harvested
and labelled unmodelled — an exercise reported as "0 questions" would be worse
than a clumsy rendering of one. The harvest skips the UI and l10n subtrees, or
a quiz reads as "Überprüfen, Wiederholen, Absenden".

Verified against this account's quiz, an H5P.QuestionSet of 20 MultiChoice
questions on a room's board: 239 tests, smoke 91/91 live-only and 93/93 with
the index.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:39:19 +02:00
MechaCat02
19d9bce2f7 Fix a flaky arm64 check in publish-image
`! docker buildx inspect | grep -q linux/arm64` under `set -o pipefail` fails
when grep exits on its first match before buildx has finished writing: buildx
dies of SIGPIPE, the pipeline reports 141, and the script tells the operator to
register emulation that is already registered. It refused to publish twice in a
row that way, with arm64 present each time — and the same race is why it ever
worked. Matching against the captured output has no pipe to break.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:46:04 +02:00
MechaCat02
ccbf3ad3e9 Document WebUntis
docs/API.md gains the findings: the endpoint and its required version
parameter, the one-time code and the two error codes worth naming, the Z that
means local time, a substitution being two periods, the unused exam module
that puts announced tests in the period notes, and a day without lessons that
is not a holiday. Those cost an afternoon of probing to learn and nothing
upstream states them.

docs/AUTH.md sets the key against the Schulcloud token it sits beside: no
password, nothing to keep alive, revocable on its own, and not read-only in
itself — which is why the allowlist exists. PI.md and DEPLOYMENT.md add the
four values, the container recreate a changed key needs, and the clock
requirement; the Pi's troubleshooting table gains both failure messages.

README and CLAUDE.md say what the server now is: Schulcloud for the material,
WebUntis for the day. Smoke is 89 checks with the index and a key, 87
live-only, 9 fewer without one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:43:24 +02:00
MechaCat02
eaf9c7aa38 Add a German prompt that prepares a school day
Tagesvorbereitung is where the two systems meet. It attaches the day's
timetable and then asks for the rest: match each lesson to its Schulcloud
course, read what is new there, look at what the previous lesson covered,
check both lists of due work, and read the notes on the periods, where the
announced tests are. Written for the morning before school, so it asks for
something short.

The argument takes heute, morgen, übermorgen or a date in either notation,
because evening preparation is the normal case and a German keyboard writes
21.09.2026. Registered only with WebUntis configured: a Tagesvorbereitung
that has to ask which lessons exist is not one.

readTimetable is shared with untis_timetable, the way readCourse is shared
with get_course, so an attached day reads exactly like a fetched one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:43:24 +02:00
MechaCat02
74b97bc4dc Read the timetable from WebUntis
Schulcloud holds the material for a lesson but not the lesson: this school's
course `times` are empty and it publishes the schedule in WebUntis. So "what
do I have today, and has anything been cancelled" was unanswerable, and the
timetable cannot be typed into a prompt either — it changes daily.

core/untis.ts talks to the API the Untis Mobile app uses, and three tools sit
on it: untis_timetable (a day or a range, Entfall, Vertretung, room changes,
the notes on each period, inline homework, the period id), untis_homework (the
class register's list, which is not Schulcloud's tasks) and
untis_lesson_topics (what earlier lessons of a series actually covered, which
is what says where a subject got to).

Read-only, but not by the Schulcloud client's rule: this API is JSON-RPC, so
every call is a POST, reads included. READ_METHODS is the guarantee instead,
enforced at the single choke point and asserted by a test. It matters because
the key can do what the app can — the live account holds W_OWN_ABSENCE, so
the same key could report the user absent.

What the live instance taught us, all recorded in docs/API.md:

- `startDateTime` ends in Z and is local time. The 08:00 lesson reports
  08:00Z, so new Date() would move every lesson by an hour or two.
- A substitution is two periods, the original CANCELLED and the replacement
  IRREGULAR beside it, not one period with a changed teacher.
- Announced tests live in the period's info text. The exam module is unused
  here, so getExams2017 is always empty and that field carries the tests.
- A day with no lessons is not a holiday: the weeks this account spends in the
  company simply have no periods.
- `?v=i3.2` is required, or the call fails with a Java NPE reported as -8998.
  Errors arrive with HTTP 200 and an error member. -8504 is a rejected key and
  -8524 a drifting clock; the tools name both, because no retry fixes either.

Configuration is all four UNTIS_* values or none — three are identifiers and
the fourth is a credential, so a half-filled block is a paste that went wrong.
Without them the tools are not registered at all, since a tool that can only
fail is worse than a missing one. whoami reports the WebUntis identity and
survives a dead Schulcloud session, so "is the server reachable" no longer
gets a misleadingly total no. mcp-env.sh switches WebUntis off for a fixture
run: that key belongs to the real school.

224 tests. All 10 WebUntis smoke checks pass, with a key and without one; the
Schulcloud checks in those runs answer 401 because this machine's session is
logged out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:43:24 +02:00
MechaCat02
87c5cedf3c Add RFC 6238 one-time codes
WebUntis authenticates the mobile app not with a password but with a static
base32 key, from which every request derives a fresh code. That is the
credential an always-on server wants: it works under the school's SSO, needs
no session, and expires only when a new key is generated.

Implemented rather than pulled in. It is HMAC-SHA1 plus a truncation,
node:crypto has the hard part, and a dependency that handles a credential is
one worth not having. The tests are the RFC's own vectors, which validate the
base32 table as much as the arithmetic.

The code comes back zero-padded, as a string. One in ten begins with a zero
and a JSON number would drop it, which is a login that fails a tenth of the
time and looks like a server fault.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:43:24 +02:00
MechaCat02
196e10eacc Keep school dates in the school's timezone
The container runs on UTC and the school does not: at 00:30 in Erfurt the
process clock still says yesterday, so anything deriving "today" from a Date
would prepare the wrong school day twice a night.

core/dates.ts holds calendar dates as plain YYYY-MM-DD strings, which is what
a school day is — today in Europe/Berlin, whole-day arithmetic anchored at
noon UTC so no daylight-saving change can shift a date, the German weekday and
day formats, and the compact form the timetable API takes.

isCalendarDate round-trips rather than only matching a shape: Date.parse turns
2026-02-30 into March 2nd instead of refusing it, so a shape check alone would
let a caller read a different day than it asked for.

germanDate moves here from mcp/prompts.ts, its only previous home, so the
timezone is stated in one place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:43:24 +02:00
MechaCat02
ab581aa5ca Give claude.ai a token of its own, sent as a request header
claude.ai's connector dialog does offer request headers, on its second step,
after the URL has been probed, so the connector no longer needs the secret
path. MCP_AUTH_TOKEN already worked there as a bearer or X-Api-Key, but it
also opens /api, which can replace the Schulcloud token and stream the file
mirror, and claude.ai stores the header's value.

MCP_CONNECTOR_TOKEN is a second token, accepted on /mcp only and refused on
/api, and rotated without touching Claude Code or the CLI. The config refuses
one shorter than 32 characters, equal to MCP_AUTH_TOKEN, or set without it,
and never echoes a value. Every accepted token is compared in full, so the
timing does not tell which one matched.

The gate also takes a bare Authorization value, because claude.ai sends a
header exactly as typed and its docs warn that most servers reject a token
entered without "Bearer ". It takes X-Auth-Token too, the other name its
dialog offers.

The docs now set up the header; the secret path stays as a fallback for
clients that cannot send one. 184 tests. Smoke 79/79 and 77/77 on the local
instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:03:56 +02:00
MechaCat02
bfccb3f343 Pull the server image on the Pi instead of building it
The Pi now runs registry.mc02.dev/schulcloud-mcp. Its compose file `!reset`s
the build section docker-compose.yml declares, so no `docker compose` command
there can fall back to building (Compose 2.24 or later). SCHULCLOUD_MCP_TAG
pins a commit's image; unset, the Pi follows `latest`.

`npm run publish-image` publishes from a development machine. It builds only a
clean working tree, so a commit tag names exactly that commit's code, for amd64
and arm64, tagged `latest` and with the short commit id. On an x86 machine
arm64 builds under QEMU, which the script checks for and explains rather than
registering unasked.

PI.md logs in to the registry, pulls and starts instead of building, and
updates by publishing and pulling. Rehearsed in a scratch compose project: the
image came from the registry, `up -d --build` built nothing, and the server
came up healthy with its database and no published ports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:26:44 +02:00
MechaCat02
bac91303bf Add a setup guide for the Pi, and a compose file its .env selects
docs/PI.md goes from a Pi with Docker to a working claude.ai connector:
configuration, the first token, Caddy, DNS, the VPS forwarding raw TCP,
checks from outside, connecting the clients, the monthly token, updates,
backups and troubleshooting.

docker-compose.override.yml is tracked, so Compose would have merged it on
the Pi as well — publishing ports and switching the crawl timer off. The
Pi's .env sets COMPOSE_FILE to add deploy/docker-compose.pi.yml instead,
which joins the existing Caddy network by name and builds DATABASE_URL, so
nothing tracked needs editing there. Postgres moves to a private network
shared only with the server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:17 +02:00
MechaCat02
ab265b5b0c Serve MCP at a secret path, so claude.ai can connect
claude.ai's connector dialog takes a name and a URL. Sending a bearer token
needs a "Request headers" beta most accounts lack, and OAuth is not built
yet, so with MCP_PATH_SECRET set the endpoint is also served at
/<secret>/mcp without the bearer token — a trial until OAuth replaces it.

The path is the credential there. It is compared in constant time, and a
wrong one answers 404 like any unknown path. The config refuses fewer than 32
URL-safe characters and never echoes the value, nothing in the server logs
request paths, and the Caddy snippet rewrites the segment before an access
log entry is written (verified against Caddy 2.11). Claude Code and the CLI
keep the bearer token; DEPLOYMENT.md says what the path trades away.

178 tests. Smoke 76/76 and 74/74 on the local instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:17 +02:00
MechaCat02
973b82ebf5 Replace the Schulcloud token without a restart
A token lasts 30 days and only a browser login yields one — the account is
federated, so the server cannot mint it. Replacing it meant editing .env and
recreating the container, every month.

`schulcloud token set` (a hidden prompt, or piped input) and a /token page
both send it to PUT /api/token. The server checks it with Schulcloud first —
well-formed, unexpired, still logged in, the same account — then swaps it
into the config every request reads, restarts the keepalive and saves it in
STATE_DIR, a new volume, with mode 0600. At startup the newer of the saved
token and TSC_JWT_COOKIE wins, unless they belong to different accounts. A
refused paste changes nothing, and the token is never logged.

The keepalive's pings carry a generation, so a 401 for the old token that
arrives after a swap cannot stop the new cycle. `schulcloud token`, whoami
and the log report the expiry and warn a week ahead.

Found on the way: a host that is off for more than two hours loses the
session however long the token has left — this machine lost it overnight —
which is what the always-on Pi is for.

174 tests. Smoke 72/72 on the local instance, and a real swap verified end to
end there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:16 +02:00
MechaCat02
9d0272c622 Offer courses and rooms as MCP resources, with two German prompts
A course or room can now be attached to a message rather than fetched:
schulcloud://courses/<id> and schulcloud://rooms/<id> carry exactly what
get_course and get_room return. Deliberately coarse — a picker lists every
resource at once, which suits some twenty courses and not a thousand files.

Two prompts, in German because the school is: zusammenfassung summarises a
course or room, and pruefungsvorbereitung prepares for an exam with practice
questions and a study plan. Each embeds the overview and says where material
hides and what cannot be read.

Claude Code shaped the details, read from its bundle rather than its docs.
It splits prompt arguments on whitespace and drops extra words, so words
arrive joined with "_", and courses match by fragments, whole words first,
so LF1 is not ambiguous with LF10. Its @ autocomplete shows a resource's
description, so the description carries the name. Errors are ProtocolError,
because McpError's message prefix is doubled by the client.

Verified in interactive Claude Code: @-mention, autocomplete and the prompt
commands. 157 tests. Smoke 67/67 live; 69/69 and 67/67 on the local instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:16 +02:00
MechaCat02
3e44e66dde Survive a first full crawl: poll, time out on silence, retry downloads
The first full crawl with the file manager ran 14 minutes, downloading every
file once, and broke in three ways:

- `schulcloud refresh` reported "fetch failed" for a crawl that was
  succeeding: Node's fetch abandons a response without headers after five
  minutes. POST /api/refresh takes wait:false and the CLI polls /api/status;
  refresh_index answers after 50 s and leaves the crawl running, and
  index_status says when a first crawl is under way.
- Downloads were bounded by the 30 s request timeout, which cut 11 MB scans
  off mid-transfer. They now time out on 30 s of silence instead.
- Failures were recorded once and never retried. A download failure is now
  retried on the next crawl while an extraction failure stays final, and PDF
  text containing NUL, which Postgres refuses, is stripped.

On the re-crawl all six failed files succeeded; only two videos above the
mirror cap stay metadata-only, by design. 137 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:16 +02:00
MechaCat02
bed3923902 Browse the file manager ("Dateien") as a filesystem
Many teachers never use topics or boards; their material sits in the
course's file area, and the tools answered "0 files" for courses holding
dozens of worksheets — 21 of 26 courses on the live account. Persönliche,
Kurs-, Team- and Geteilte Dateien live in the legacy file store, not in
files-storage, and its service is not in the public ingress. The only way in
is the legacy client: HTML listings, and GET /files/signedurl for a
pre-signed download.

core/legacy-files.ts turns that into one path tree — /my, /courses/<course>,
/teams/<team>, /shared — resolving names that contain "/", ids anywhere in a
path, and wrong or ambiguous names with a message saying what is there. A
listing that does not parse throws; it never reads as an empty folder.

Some of the legacy client's GET routes write (GET /files/share/ mints a
share token), so getFileManagerPage allows only the listing routes, by
pattern. Signed URLs are fetched with no credentials and must be https.

- MCP: fs_list, fs_tree, fs_find and fs_read; get_course lists course files.
- CLI: schulcloud fs ls, tree, find and get, recursive and resumable.
- API: /api/fs/list, tree, find and file.
- Index: the crawl walks the file manager (INDEX_FILE_MANAGER, on by
  default), so search covers the text inside those files and sync mirrors
  them under <course>/Kurs-Dateien.

The local instance gains a fixture for all four areas. It needed a loopback,
so signed URLs open from the host, and a pre-created bucket, since MinIO
does not implement PutBucketCors.

135 tests. Smoke 55/55 live; 57/57 and 55/55 on the local instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:16 +02:00
MechaCat02
10c6544579 Prepare for a live account: separate the indexes, and probe new routes live
With .env pointing at the real account, testing against the local instance
became dangerous: process env beats --env-file, so a local smoke run would
crawl fixtures straight into the live index, where a per-course refresh
carries them forward indefinitely. mcp-env.sh now pins its own database
(schulcloud_local) and mirror as well as the instance.

The override publishes the server on MCP_HOST_PORT and takes
CRAWL_INTERVAL_MS from .env, since what_changed can only report what
happened between crawls. The build context leaves out tmp/, which holds a
mirror of the account's files, and local-instance/.

probe checks live what the gap fixes established locally: the /api/v1 course
and user routes, classes, room allowedOperations as an object, and the
preview enums on a real file. A failure there means a feature degrades
rather than breaks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:16 +02:00
MechaCat02
5ae2210459 Close the gaps an audit of courses, tasks, files and grades turned up
Every area — courses, rooms, boards, topics, tasks, files, quizzes, teams,
groups, submissions, grades — was checked for data the instance has and the
tools did not show.

Grades and feedback. A teacher's /homework page is a different page from a
student's: grade and comment live in the grading form, one block per
submission, so a teacher account reported every graded submission as having
neither. parseTeacherGrading reads the form, and list_submissions can now
include the written feedback and who handed the work in.

Names. /api/v1 is partly served: courses, users and classes survive in the
deployment's ingress table, and users/{id} is the only route from an id to a
name. Submitters, file creators and course teachers resolve through it, and
degrade to "not visible to this account" where a student may not read them.

Courses, rooms and classes. get_course adds the description, teachers,
member count and weekly timetable from /api/v1/courses. list_classes is new.
get_room reports what the account may do — allowedOperations is an object of
booleans, not the list it was typed as — and applicants and invitation links
where it may manage them.

Board and topic content. Link descriptions, image alt text, drawing and
video-conference titles, the ids behind external tools and H5P content (the
only thing resembling a quiz), and what a deleted element used to be. Topic
Etherpad pads are read like board pads, and htmlToText keeps table columns
apart and drops template indentation.

Files. A scan with no text layer falls back to the preview endpoint, whose
width and outputFormat are undocumented enums, so Claude gets a picture of
the page; list_files reports counts and sizes. Teams stay documented as
unreadable at any API version; their files come later.

What the crawl missed. Tasks attached to topics (18 of 60 on the live
account), each course's own file area, and — behind INDEX_PERSONAL_FILES —
personal files and submissions with their grade comments, so search and
what_changed cover grading. A submission hit points at get_task.

The local instance's preview profile gets an ImageMagick policy that allows
the coders its 7.1.2 build needs; the image's own denies them all.

110 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:16 +02:00
MechaCat02
a3b17a680c local-instance: raise the proxy's header buffers, or topic pages 502
A topic page makes the legacy client set several large cookies at once —
jwt plus the Etherpad session it asks for — which overflows nginx's default
4k header buffer. The upstream answered 200 and the proxy still returned 502
with "upstream sent too big header".

It went unnoticed because the MCP server's topic-page scrape degrades to an
empty list by design: the task ids it recovers from that page silently
vanished instead of failing. Changed in the generator and in the generated
config.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:16 +02:00
MechaCat02
237395e4f4 Run the tests on Node 22.16, and fix what a fresh setup tripped over
Node 22.16 runs TypeScript only behind --experimental-strip-types (on by
default from 22.18), so `npm test` failed on every file with
ERR_UNKNOWN_FILE_EXTENSION. The flag is harmless on newer versions.

probe and session-diagnose still imported dist/schulcloud/client.js, which
the move to core/ renamed, so both scripts died at import.

A relative MIRROR_DIR broke file serving: res.sendFile refuses a relative
path, and resolveWithin returns an absolute path only when its root is one.
The config resolves it once, at load.

package-lock.json is what `npm install` records today: the `schulcloud`
bin, and no stale peer flags. 101 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:15 +02:00
MechaCat02
1de026ca43 Serve rooms ("Räume"), which are not courses however the urls read
The account this was built against is in no rooms, so the whole space was
invisible and easy to dismiss as an empty endpoint. It is not empty in
general — the user had rooms until a teacher removed access — and the
UI's naming actively hides the distinction: the sidebar's *Kurse* entry
links to `/rooms/courses-overview` and lists courses, while *Räume* links
to `/rooms` and lists rooms. A url containing `/rooms` identifies neither.

list_rooms and get_room cover the latter. A room holds boards and nothing
else, so get_room lists boards for get_board (which already reports "in
room" from the board context) plus who else is in it. Room boards report
`isVisible`, which the course-page projection does not, so a draft is
named as a draft instead of being offered and then answering 403.

Rooms also go through the crawl, or they would have become the next
blind spot: their boards are indexed, searchable by both the index and
the live-crawl path, diffed by what_changed, and mirrored by the CLI
under the room's name. The board traversal and the snapshot matcher are
now shared between courses and rooms rather than duplicated, which also
fixed the live-crawl path silently not searching pad contents.

The CLI needed no new command — it is file-centric and inherits rooms
through the manifest — but `--course` now accepts a room id, and says so.

`kind` gains 'room'; the column is plain TEXT, so no migration. 112 tests.
Smoke: 42/42 and 44/44 local, 41/41 and 43/43 live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 19:24:53 +02:00
117 changed files with 18726 additions and 472 deletions

View File

@@ -11,3 +11,8 @@ scripts
!scripts/copy-assets.mjs
docs
files.zip
# Mirrored coursework and server logs: never needed to build, and the mirror
# holds the account's files, which have no business in a build context.
tmp
# The local test instance has its own compose project.
local-instance

View File

@@ -10,6 +10,8 @@ TSC_URL=https://schulcloud-thueringen.de
# TTL that the built-in keepalive holds open. IMPORTANT: close the Schulportal
# window after copying this — an open tab shares the session and its auto-logout
# will revoke this token ~2h after login. See docs/AUTH.md.
# Needed for the first start. Later tokens go in with `schulcloud token set` or
# the server's /token page, without a restart; a saved newer one wins over this.
TSC_JWT_COOKIE=
# ---------------------------------------------------------------------------
@@ -22,10 +24,33 @@ TSC_JWT_COOKIE=
# openssl rand -hex 32
MCP_AUTH_TOKEN=
# claude.ai: the token its connector sends as a request header
# (`authorization: Bearer <token>`). Accepted on /mcp only, never on /api — which
# can replace the Schulcloud token — because claude.ai stores it. At least 32
# characters, different from MCP_AUTH_TOKEN; unset = off. Generate one with:
# openssl rand -hex 32
# MCP_CONNECTOR_TOKEN=
# Clients that cannot send a header: serve MCP at /<this value>/mcp with no
# token at all. The URL becomes the credential — see "Connecting Claude" in
# docs/DEPLOYMENT.md before using it. At least 32 URL-safe characters; unset =
# off. Generate one with:
# openssl rand -hex 32
# MCP_PATH_SECRET=
# Where a Schulcloud token replaced at runtime (`schulcloud token set`, /token)
# is saved, so a restart keeps it. docker-compose.yml sets /data/state; unset =
# replacements last until the next restart.
# STATE_DIR=/data/state
# Listen address inside the container. Leave as-is when running behind Caddy.
PORT=8080
BIND_HOST=0.0.0.0
# Local Docker only: the loopback port docker-compose.override.yml publishes the
# container on. Default 8080; change it when that port is already taken.
# MCP_HOST_PORT=8080
# ---------------------------------------------------------------------------
# Index and file mirror (optional — without these the server runs live-only:
# search crawls on every call, and the CLI's /api surface is unavailable)
@@ -42,11 +67,83 @@ DATABASE_URL=postgresql://schulcloud:schulcloud@postgres:5432/schulcloud
# still downloadable, proxied live. Default 64 MiB.
# MIRROR_MAX_BYTES=67108864
# Also index personal files ("Meine Dateien") and submitted / returned work,
# including teacher grade comments. This is what makes "what did the teacher
# say about X" searchable and lets what_changed report a re-grade. Costs roughly
# three extra requests per task on a full crawl, so it is off by default.
# INDEX_PERSONAL_FILES=false
# Also walk the file manager ("Dateien") when crawling: Kurs-Dateien for every
# course, plus Persönliche, Team- and Geteilte Dateien on a full crawl. Many
# teachers keep their material only there, so this is on by default. One page
# load per folder — about 160 on a 26-course account.
# INDEX_FILE_MANAGER=true
# How often to re-crawl on a timer, in ms. Default 21600000 (6h). 0 = on demand
# only. A re-crawl of unchanged content downloads nothing, because Schulcloud
# file records are immutable.
# CRAWL_INTERVAL_MS=21600000
# ---------------------------------------------------------------------------
# Your own lesson notes (optional — what you wrote down in class)
# ---------------------------------------------------------------------------
# Directory of Markdown files holding your own notes. With it set, the server
# offers list_notes, get_note and add_note, indexes the notes so `search` finds
# them, and the German prompts consult them. Unset = none of that exists.
# docker-compose.yml sets /data/notes and gives it a volume; bind-mount a synced
# folder there instead to write notes from a phone. See docs/NOTES.md.
# NOTES_DIR=/data/notes
# Leave the files alone: list_notes and get_note still work, add_note and
# POST /api/notes are refused. Right for a deployment whose notes are synced in
# from somewhere else and should have exactly one writer.
# NOTES_READONLY=false
# Password for the web app at /app — the notes editor and the settings page
# where the Schulcloud token is replaced. Unset = the app is not served at all.
#
# Unlike every other credential here this one is typed by a person on a phone,
# so it is a passphrase rather than a random token: at least 12 characters, and
# three or four words is the right shape. It is hashed with scrypt at startup
# and the plain value is never stored, compared or logged. Changing it logs out
# every session, because the session signing key is derived from it.
#
# The app is on the internet like the rest of the endpoint. Failed logins are
# rate-limited per address, but the password is the thing protecting the notes
# and the Schulcloud token — pick a long one.
# WEB_PASSWORD=
# ---------------------------------------------------------------------------
# WebUntis (optional — the timetable, which Schulcloud does not hold)
# ---------------------------------------------------------------------------
# Where the school publishes its timetable. With these set, the server offers
# untis_timetable, untis_homework and untis_lesson_topics, plus the German
# "tagesvorbereitung" prompt; without them none of that exists. All four values
# are in one dialog: WebUntis → Profil → Freigaben → Untis Mobile → QR-Code.
#
# UNTIS_SECRET is the "Schlüssel" field and is a credential: it authenticates
# every request as this user, needs no password, works with an SSO login, and
# stays valid until you generate a new key in that dialog. It can do whatever
# the Untis Mobile app can — this server only ever calls read methods, by
# allowlist (src/core/untis.ts). See docs/AUTH.md.
#
# Authentication is a time-based code, so the host's clock must be in sync;
# WebUntis answers -8524 ("invalid client time") when it is not.
# UNTIS_SERVER=yourschool.webuntis.com
# UNTIS_SCHOOL=yourschool
# 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)
# ---------------------------------------------------------------------------

355
CLAUDE.md
View File

@@ -6,11 +6,19 @@ Guidance for Claude Code when working in this repository.
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. TypeScript, Node 22+,
`@modelcontextprotocol/sdk`.
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, and **the user's own lesson notes**, a
directory of Markdown files that is the only record of what was actually said in
the room. TypeScript, Node 22+, `@modelcontextprotocol/sdk`.
Three sources, and the distinction matters in every tool description: Schulcloud
has the material, WebUntis has the schedule and the class register, the notes
have what the teacher stressed. An answer that silently merges them is worse
than one that says which said what.
Three entry points over one core:
- `src/bin/http.ts` — Streamable HTTP + `/api`, the deployed form, behind Caddy on a Pi.
- `src/bin/http.ts` — Streamable HTTP + `/api` + `/app`, the deployed form, behind Caddy on a Pi.
- `src/bin/stdio.ts` — stdio, for local Claude Code / Desktop use.
- `src/bin/cli.ts` — the `schulcloud` CLI, which talks to the HTTP server, never
to Schulcloud.
@@ -26,18 +34,43 @@ npm run probe # verify token + API assumptions against the LIVE instance
npm run smoke # full end-to-end: real server + real MCP client + real data
npm run keepalive-status # is the deployed container holding its session?
npm run session-diagnose # ~2.5h: measure what actually ends the session
npm run publish-image # amd64 + arm64 image to registry.mc02.dev; clean tree only
```
`docker-compose.override.yml` is local-only and publishes the server on
`127.0.0.1:8080` and Postgres on `127.0.0.1:55432`; see `docs/LOCAL.md`.
`127.0.0.1:8080` (or `MCP_HOST_PORT`) and Postgres on `127.0.0.1:55432`; see
`docs/LOCAL.md`. Compose merges it whenever `COMPOSE_FILE` is unset, so the Pi's
`.env` sets `COMPOSE_FILE=docker-compose.yml:deploy/docker-compose.pi.yml`
instead; `docs/PI.md` is the setup guide. **The Pi never builds**: that file runs
`registry.mc02.dev/schulcloud-mcp` and `!reset`s the build section, and
`npm run publish-image` is how an image gets there — so a change reaches the
Pi only once it is committed and published. When `.env` points at the live account, test against the local
instance only through `local-instance/scripts/mcp-env.sh`: it pins its own
database (`schulcloud_local`) and mirror, so fixtures cannot reach the live
index.
`probe` and `smoke` hit the live Schulcloud and need a valid `.env`. Both are
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 (34 checks, index-backed) and
without (32 checks, live-only). The degradation path is a supported mode, not a
fallback nobody exercises.
Run smoke **both ways**: with `DATABASE_URL` set (index-backed) and without
(live-only). Without a WebUntis key the run asserts the `untis_*` tools are
*not* offered instead of exercising them, and the same holds for `NOTES_DIR`
except that the smoke sets its own throwaway one, so the note tools are always
exercised and can never touch real notes. The degradation paths are supported
modes, not fallbacks nobody exercises.
The check counts are a tripwire, so re-measure them rather than trusting this
line after a change: **106/107 against the local instance** on 2026-09-19 (the
one failure is the H5P service, which that instance does not run). The live counts
are stale — they were last taken before the notes and class-register work, and
could not be retaken because the live session had lapsed. Every Schulcloud check fails with 401 when the live
session has lapsed — check the container's keepalive log before suspecting code.
The editor's Markdown round trip is unit-tested; the **browser** side of
`editor.js` is not, because nothing here runs one. It was checked by hand in
Firefox against a page that drives the toolbar — WebKit, which is the engine on
the phone this is written on, has still never run it.
Store tests need a database and skip without one:
`TEST_DATABASE_URL=postgresql://… npm test`. They use a real Postgres on
@@ -49,16 +82,17 @@ once put fixtures into real data.
## Architecture
```
bin/{http,stdio}.ts ─┬─ mcp/server.ts ── mcp/tools/*
bin/{http,stdio}.ts ─┬─ mcp/server.ts ── mcp/tools/*, mcp/{resources,prompts}.ts
└─ http/{server,api,auth}.ts /mcp and /api
bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync}.ts
services.ts (process-wide: client, Store, Indexer)
services.ts (process-wide: client, Store, Indexer, UntisClient)
indexer/indexer.ts ── store/store.ts ── Postgres
core/{client,board,crawl,extract,text,paths,types}
core/{untis,totp,dates} ── WebUntis, a second upstream
```
- **`core/`** knows nothing of MCP, HTTP or the CLI.
@@ -67,13 +101,106 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
- **`crawl.ts`** — the one traversal. Search, the indexer, the what-changed
diff and the file mirror all need it; keep it here, not in a tool.
- `paths.ts` — the security boundary for mirrored filenames. See Invariants.
- `legacy-files.ts` — the file manager ("Dateien": Persönliche, Kurs-, Team-,
Geteilte Dateien) as one path tree, parsed from the legacy client's pages.
A separate store from files-storage; the `fs_*` tools and `/api/fs` sit on it.
- `session-token.ts` — the Schulcloud token, replaceable at runtime: checked
with `GET /me` (same `userId`), swapped into `config.jwt`, saved to
`STATE_DIR`. **Read `config.jwt` at the moment of use; never keep a copy.**
- **`untis.ts`** — WebUntis, the second upstream: the timetable with its
cancellations and substitutions, class-register homework and lesson topics.
Authenticates each request with a one-time code (`totp.ts`) over the key
from Profil → Freigaben, so there is no session and no keepalive on this
side. Resolves the payload's element ids to names and returns every day in
a range, empty ones included. See Invariants for why the allowlist is there.
- **`day-note.ts`** — a school day as a note: the timetable turned into one
`##` heading per lesson. `lessonHeading` and `notes.ts`'s
`subjectFromHeading` are a **loop** — the app writes the heading and the
indexer reads the subject back out of it, so a change to either without the
other files a day's notes under nothing. `test/day-note.test.ts` holds them
to it.
- **`notes.ts`** — the user's own lesson notes as a directory of Markdown
files with a small frontmatter dialect. A note with `##` headings is a
school day and is indexed **per lesson**, not whole: indexed whole, every
hit would read "my note, Monday" and "what did we do in Deutsch" would match
a note whose other five lessons were something else. The files are the truth and the
index is a view of them, so `list_notes`/`get_note` read disk and answer
before the first crawl and while Postgres is down. **The one thing anything
here writes** — see Invariants. `docs/NOTES.md` is the guide.
- **`untis-history.ts`** — the class register read backwards, which is what
puts "what did we actually cover" into the search index. Its whole reason
for existing is one API property: `getLessonTopic2017` answers per *series*,
so a term costs one call per lesson series rather than one per period — see
API gotchas.
- `h5p.ts` — the quizzes on a board. One GET per element, parsed into
questions and answers; board assembly attaches it like a pad, the crawl
indexes its text, and `get_h5p` prints it.
- `dates.ts` — school days as `YYYY-MM-DD` in Europe/Berlin. The container
runs UTC, so `schoolToday()` is not `new Date()`: at 00:30 in Erfurt the
process clock still says yesterday, and a nightly briefing would prepare
the wrong day.
- **`http/`** — `/mcp` and `/api` take `MCP_AUTH_TOKEN`; `/mcp` alone also takes
`MCP_CONNECTOR_TOKEN`, the request header claude.ai stores, which must never
open `/api`. Besides those: the optional `/<secret>/mcp` for clients without
headers (`MCP_PATH_SECRET`), and `/token`, a page that PUTs a fresh token to
`/api/token`. docs/AUTH.md and docs/DEPLOYMENT.md say why each exists.
- **`http/app-page.ts` + `http/app/`** — `/app`, the one surface meant for a
person rather than a program: the day's notes and a settings page for the
Schulcloud token. Served only when `WEB_PASSWORD` is set. Its assets are
**files** under `src/http/app/`, copied to `dist/` by `scripts/copy-assets.mjs`
and read relative to `import.meta.dirname` — real HTML, CSS and JS that an
editor and a linter understand, which is also what the CSP requires, since it
forbids inline script. `app.js` is an ES **module**; a new asset must be added
to `ASSETS` *and* to the route's regex in `app-page.ts`, or it 404s.
- **`http/app/markdown.js` + `editor.js`** — the note is edited as formatted
text and stored as Markdown, and these two are that translation.
`markdown.js` is the pair `markdownToHtml` / `markdownFromDom`; `editor.js`
drives a `contenteditable` element with `execCommand` (no library: the CSP
allows no outside script and the app has no bundler). **The round trip must
settle**: one pass may tidy a note, a second must change nothing, and
`editor.js` checks exactly that before opening a note formatted — a note that
fails opens in the Markdown view instead. `test/app-markdown.test.ts` covers
it against `test/mini-dom.ts`, ~60 lines of read-only DOM, because losing a
lesson's notes to a lossy serializer is not a bug anyone can recover from.
Pasted HTML goes through Markdown before it reaches the document, which is
where sanitising and formatting are the same operation.
- **`http/web-auth.ts`** — the app's login, which is a different kind of
credential from everything else here: a password a person types, not a token a
program was configured with. scrypt at startup, a signed `HttpOnly` /
`SameSite=Strict` session cookie, per-address rate limiting. The session key
is **derived from the password**, so changing it logs every session out and
there is no second secret to store. The cookie opens `/api` — a session *is*
the user — and never `/mcp`. See Invariants.
- **`store/`** — crawl generations, identity diffs, `german` + `pg_trgm` FTS.
`Store.open` returns `undefined` when Postgres is down; callers degrade.
- **`indexer/`** — crawl → persist → mirror bytes → extract text → index.
Coalesces concurrent refreshes; enforces a minimum interval.
Coalesces concurrent refreshes; enforces a minimum interval. The crawl walks
topic-attached tasks too, which the course page does not list: without that
they are unsearchable and their grades invisible. `INDEX_PERSONAL_FILES`
additionally indexes personal files and submitted/returned work, including
grade comments — that is what makes "what got graded this week" answerable,
at roughly three extra requests per task.
- **`mcp/tools/*.ts`** — tool descriptions are prompts: they are how Claude picks
a tool, so they carry the German domain terms (Kurse, Themen, Aufgaben) and say
when *not* to use the tool.
- **`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
`tagesvorbereitung`, which attaches a day's timetable and is the point where
the two systems meet. What people read in a picker (labels, prompt texts) is
German; what the model reads stays English.
- **`context.ts`** — per-session state. Only `/me` is cached, because the school
id is on every files-storage path and cannot change for a token.
@@ -83,8 +210,44 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
`GET` except `extendSession` (the keepalive's `refresh-session` call, which
touches only our own session and is not exposed as a tool, so no model-driven
call can be a POST). `api_get` rejects non-`/api/` paths and anything carrying a
scheme or host. `refresh_index` and `POST /api/refresh` write only to the Pi's
own index and mirror — every upstream call they make is still a GET.
scheme or host. **Against the legacy client, GET-only is not enough:**
`GET /files/share/` mints a share token and `GET /files/file?share=` grants a
permission, so `getFileManagerPage` allows only the listing routes, by pattern —
widen that pattern only with a route you have read the handler of. A pre-signed
download URL is fetched with **no** credentials: it names another host, and
neither the bearer nor the `jwt` cookie may go with it. `refresh_index` and `POST /api/refresh` write only to the Pi's
own index and mirror, and `PUT /api/token` only to the server's own token — every
upstream call they make is still a GET.
**The app's session opens `/api`, never `/mcp`, and the app is not served
without a password.** `WEB_PASSWORD` is the only credential here a human types,
so it is the only one that can be guessed: the rate limiter in `web-auth.ts` is
not decoration, and the scrypt cost that makes guessing expensive is itself a
denial-of-service vector without it. The password is hashed at startup and never
stored, compared or logged in the clear — it is a secret by the rule below, and
so is the session cookie. Unset means the app does not exist, the same rule the
`untis_*` and note tools follow: a login screen no password can open is worse
than no page, because it looks like a way in.
**The notes directory is the only thing anything here writes to.** That is not
an exception to the invariant above — it is a different store: the user's own
files, never Schulcloud and never WebUntis. It is bounded by the same two
functions as the file mirror, and for the same reason: `add_note`'s title and
subject arrive from a tool call, become path components through `safeComponent`,
and the result is checked by `resolveWithin`, so a note titled
`../../.ssh/authorized_keys` becomes a filename. `NOTES_READONLY` refuses writes
entirely. Do not widen this to anything outside `NOTES_DIR`, and do not take it
as precedent for a Schulcloud write tool — that decision is the one above, and
it has not changed.
**WebUntis is read-only by allowlist, not by verb.** Its API is JSON-RPC, so
every call is a POST, reads included — "GET only" cannot carry over. Instead
`core/untis.ts` holds `READ_METHODS` and `assertReadMethod` refuses anything
else at the single choke point, which `test/untis.test.ts` asserts. This matters
because the key is the mobile app's credential and can do what the app can: the
live account's rights include `W_OWN_ABSENCE`, i.e. that key could report the
user absent. Add a method only after reading what it does upstream, and never
one whose name starts with `submit`, `create`, `save` or `delete`.
**Filenames from Schulcloud are untrusted paths.** Course titles, card titles
and filenames are all user-supplied upstream, and both the server's mirror and
@@ -98,8 +261,14 @@ the property that makes that acceptable. Do not add a write tool without the
user explicitly asking for one and understanding this.
**Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the
account; `MCP_AUTH_TOKEN` guards the endpoint. Neither belongs in
account; `MCP_AUTH_TOKEN`, `MCP_CONNECTOR_TOKEN` and `WEB_PASSWORD` guard the
endpoint;
`UNTIS_SECRET` authenticates as the user in WebUntis and outlives every other
credential here, since it does not expire. None belongs in
logs, error messages, or tool output. `.env` is git-ignored — keep it that way.
Two more count as secrets: a token replaced at runtime (it lives only in
`STATE_DIR`, mode 0600) and, when `MCP_PATH_SECRET` is set, **request paths**
so nothing may log a URL path, and config errors describe the rule, not the value.
**Live behaviour beats upstream source.** The clones in `vendor/` track `main`
and may be ahead of what is deployed. When they disagree with the instance, the
@@ -111,7 +280,14 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
- Course contents are at `GET /api/v3/course-rooms/{courseId}/board`. There is
no `GET /api/v3/courses/{id}`, and `:roomId` there is the *course* id.
- `/api/v3/rooms` is an unrelated newer feature, not courses. Empty is normal.
- **Rooms ("Räume") are a separate space from courses, and the UI's naming is a
trap**: the sidebar's *Kurse* entry links to `/rooms/courses-overview` and
lists courses; *Räume* links to `/rooms` and lists rooms. A `/rooms/...` url
says nothing about which. `list_rooms`/`get_room` cover the latter; a room
holds boards only — no lessons, no tasks. Empty is normal and is also what a
revoked membership looks like.
- Room boards report `isVisible`, which the course-page projection does not, so
a room's drafts can be named as drafts instead of being tried and 403ing.
- `limit` is rejected above 100 though the spec says 99. Page at 99; the client
clamps and `listAllCourses` pages for you.
- There is no `GET /tasks/{id}`, and the task lists omit `description` — it
@@ -123,6 +299,18 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
- **Never swallow a per-item crawl error.** Board failures used to be caught and
dropped, so the index lost whole boards while the crawl reported success —
which is how the 20-id limit went unnoticed. They go into `Snapshot.failures`.
- **Record failures by kind, or transient ones become permanent.** Every failed
file used to be marked done, so a timeout was never retried. A *download*
failure is now recorded with `retry: true` and the next crawl tries again; an
*extraction* failure is final. Two causes found on the first full crawl of a
real account: downloads bounded by the 30 s request timeout (11 MB scans cut
off mid-transfer — downloads now time out on 30 s of *silence*), and PDF text
containing NUL, which Postgres `text` refuses — stripped in `recordFileText`.
- **A full crawl can outlast one HTTP request.** The first one with the file
manager took 14 minutes (every file downloaded once); Node's fetch abandons a
response without headers after 5. `POST /api/refresh` takes `wait: false` and
the CLI polls `/api/status`; `refresh_index` returns after 50 s and leaves the
crawl running. Don't reintroduce a caller that waits on a full crawl inline.
- **`GET /lessons/{id}/tasks` is a bare array whose items carry no id.** Not the
`{data,total}` envelope, and `LessonLinkedTaskResponse` has no id field at
all. A topic-attached task is thus unidentifiable from the API and invisible
@@ -139,8 +327,25 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
so the store's digest has to include the name.
- **Submissions: only `GET /submissions/status/task/{taskId}` exists.** No list,
no fetch-by-id, and the payload has no submitted text, grade comment or
graded-at`/api/v1`, which had them, is not served here. Don't imply absent
feedback means none was given.
graded-at. Don't imply absent feedback means none was given.
- **`/api/v1` is partly served, and it is production surface.** Exactly three
legacy routes survive in the deployment's own ingress table
(`dof_app_deploy/ansible/group_vars/all/x_ingress.yml`): **`/api/v1/courses`,
`/api/v1/users`, `/api/v1/classes`**. Everything else under `/api/v1` is
unrouted and 404s. They matter because v3 dropped things they still carry:
`courses` has the description, `teacherIds`, `userIds` and `times` (the weekly
timetable), and `users/{id}` is the **only** way to turn a user id into a name
— submission `submitters`, file `creatorId` and course `teacherIds` are
otherwise unreadable. Permission is per-account: a teacher may read their
students, a student may read only themselves, so name resolution must degrade
to "not visible to this account" rather than printing a bare id.
- **The teacher's homework page is a different page from the student's.** Its
tabs are `extended` and `submissions`, not `submission` and `feedback`, and
the grade lives in the grading *form* (`name="grade"`, `name="gradeComment"`,
one block per `submissionId`) rather than in rendered prose. The student
parser finds nothing on it, which is why a teacher account reported every
graded submission as "neither a percentage nor feedback was found" while the
data was plainly there. `parseTeacherGrading` handles that side.
- **A grade is a percentage (`Number` 0-100) or absent; there is no text grade.**
Teachers commonly grade with `gradeComment` alone, so "graded by feedback" is
a complete answer. `formatGradeState` in `mcp/tools/submissions.ts` owns that
@@ -167,11 +372,56 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
dedicated endpoints (`/boards/{id}`, `/cards`, file records) are stable.
- Many course PDFs are **image-only scans with no text layer** (3 of 4 sampled),
so extraction legitimately yields nothing. `extract.ts` detects this and says
so; do not "fix" it by retrying.
so; do not "fix" it by retrying. `download_file` then falls back to
`GET /file/preview/...`, which renders the page as a picture Claude can read —
the answer for a scan, though it still leaves the file unsearchable.
- **The preview endpoint has two enums, and both 400 without saying so.**
`width` accepts only **50, 150 or 500** — a number outside that set is a
validation error naming the value but not the permitted set. `outputFormat`
accepts only **`image/webp`**; omitting it is worse than wrong, because the
preview is then rendered in the *source* format and a PDF comes back as a
PDF. The response also labels itself `webp` rather than `image/webp`, so the
content type has to be normalised before anything will treat it as an image.
- **A room's `allowedOperations` is an object, not a list.** Every operation is
present with a boolean; `false` means denied. Typing it as `string[]`
type-checks and throws `.some is not a function` the moment anything reads it.
- **Schulcloud has no quiz of its own — a quiz is H5P.** There is no quiz module
or endpoint upstream: interactive exercises are `h5p` elements carrying a
`contentId`, or external (LTI) tools behind `contextExternalToolId`. Don't
look for a quiz API; look for the H5P one.
- **One request holds a whole quiz**: `GET /api/v3/h5p-editor/params/{contentId}`
returns the JSON the player is fed — every question, every option and which
are correct — even though the player shows one question at a time. Nothing to
step through, no page to scrape, and `play/{id}` is the same content plus 24 KB
of scripts, so `params` is both cheaper and complete. The H5P service is not
in `docs-json` and has no document of its own; `core/h5p.ts` interprets the
payload, whose shape belongs to the H5P library the teacher used. Model a new
library there rather than in a tool, and keep the generic harvest as the
fallback — an exercise arriving as "0 questions" is the bug that path exists
to prevent.
- **The file manager is a third store, reachable only as HTML.** Persönliche,
Kurs-, Team- and Geteilte Dateien live in the legacy `files` collection, not in
files-storage: `list_files` answers 0 for a course holding dozens of
worksheets, and 21 of 26 live courses keep material there. Its Feathers service
is not in the ingress, so listings are parsed from `/files/{my,courses,teams,shared}`
pages and downloads go through `GET /files/signedurl` (JSON). Folders are
addressed by id alone — `/files/courses/{course}/{folder}` at any depth.
`permittedDirectories` returns every course with **no** folders (it queries
`refOwnerModel: 'courses'`, records say `'course'`), and `/files/search/` 504s,
so walk listings. Course names contain `/`; resolve by joining segments. A
listing page that does not parse must throw, never read as an empty folder —
"0 files" is the bug this exists to fix. `docs/API.md` has the evidence.
- **Teams cannot be read at any version.** v3 exposes only
`GET /team/{teamId}/news`; upstream `main`'s teams controller is write-only
(`POST :teamId/create-room`). `/teams` is the legacy client's HTML page, not
an API. A team's *files* are reachable, through the file manager (`/teams/<team>`).
- **`exp` (30 days) is not the session lifetime.** The binding limit is a Valkey
whitelist entry with a `JWT_TIMEOUT_SECONDS` TTL (7200s; live value at
`GET /api/v3/config/public`) that every authenticated request re-sets.
`src/keepalive.ts` holds it open — don't remove it.
`src/keepalive.ts` holds it open — don't remove it. It cannot hold it across
downtime: a host off for more than two hours loses the session (seen when a
dev machine was off overnight), which is why the deployment is an always-on
Pi and why a fresh token can be swapped in without a restart.
- **A Schulportal tab left open revokes our token.** The `jwt` cookie *is* the
browser's session token, same `jti`. The front end runs a client-side timer
(reset only on route change, never from the server TTL) and calls
@@ -180,6 +430,36 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
the tab. This produced two false conclusions before being found — if a token
dies ~2h after login, suspect an open tab first. `docs/AUTH.md` has the chain.
WebUntis has its own set; `docs/API.md` has them all, these are the ones that
cost the most:
- **`jsonrpc_intern.do` needs `?v=i3.2`.** Without it the call fails with
`-8998` wrapping a Java NullPointerException, which reads like a malformed
body and is not. Errors also arrive with **HTTP 200** and an `error` member,
so check the body before the status.
- **The one-time code travels as a string.** One code in ten begins with a
zero, and a JSON number drops it — a login that fails 10% of the time, which
is the worst kind of failure to debug.
- **`startDateTime` ends in `Z` and is local time.** `2026-09-21T08:00Z` is the
08:00 lesson in Erfurt. Never hand these to `new Date`; `splitLocal` takes
the string apart. Every date the tools send is computed in Europe/Berlin
(`core/dates.ts`), because the container is UTC.
- **A substitution is two periods**: the original with `is: ["CANCELLED"]` and
the replacement beside it with `is: ["IRREGULAR"]`. A lesson is not "changed
in place", so both have to be read, and `orgId` on an element exists as well.
- **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.
## Conventions
- Imports use `.ts` extensions; `rewriteRelativeImportExtensions` makes `tsc`
@@ -197,16 +477,45 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
## Adding a tool
1. Add the client method in `core/client.ts` (`GET` only).
1. Add the client method in `core/client.ts` (`GET` only) — or, for the
timetable side, in `core/untis.ts`, whose method name must go in
`READ_METHODS` and must read.
2. Register the tool in the relevant `mcp/tools/*.ts`, with a description that
says when to use it *and when not to*.
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.
saying what is unavailable and what still works. A `untis_*` tool instead
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
Claude Code is the client these are tested in, and it shapes them. Read from its
bundle (2.1.272), not its docs:
- **A prompt command's arguments are split on whitespace, and extra words are
dropped** (`zipObject(argNames, input.split(/\s+/))`). A value of several words
can only arrive joined, so `argumentText` turns `_` back into spaces and reads
`-` as "skipped"; course names match by fragments (`resolveTarget`). Any new
free-text argument needs the same treatment.
- **The @ autocomplete fuzzy-matches `name` but displays `description`**, falling
back to the name only when there is none — a description must carry the name.
- **An @-mention resolves only URIs from `resources/list`**; a template alone
cannot be mentioned. `McpServer` returns every template's listing in one reply
and ignores cursors, which suits a few dozen entries and not the file manager.
- **Throw `ProtocolError`, not `McpError`**: McpError prefixes its message with
"MCP error <code>:", the client prefixes it again, and people read both.
- A resource listing that fails yields no entries rather than an error: one
refused kind would otherwise cost the whole reply.
## Environment
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`. See `.env.example`
for the full set and `docs/AUTH.md` for refreshing the JWT. `npm run probe`
reports both clocks: days until hard expiry and seconds of idle budget left.
`.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),
`NOTES_DIR`, `UNTIS_HISTORY_DAYS` and `WEB_PASSWORD` (the app at `/app`);
docker-compose sets `STATE_DIR` and `NOTES_DIR`. See `.env.example` for the
full set and `docs/AUTH.md` for refreshing the JWT — `schulcloud token set`,
no restart. `npm run probe` and `schulcloud token` report the clocks: days until
hard expiry and the session budget.

View File

@@ -30,12 +30,16 @@ COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
# The mirror is the one writable path. Creating it in the image with the right
# owner matters: Docker initialises a new named volume from the image directory,
# The mirror, the state directory (a replaced session token) and the notes are
# the only writable paths. Creating them in the image with the right owner
# matters: Docker initialises a new named volume from the image directory,
# including its ownership, so without this the volume lands root-owned and the
# unprivileged user gets EACCES on every write — with the failure recorded per
# file rather than crashing, which makes it easy to miss.
RUN mkdir -p /data/mirror && chown -R node:node /data
# unprivileged user gets EACCES on every write — with the failure recorded
# rather than crashing, which makes it easy to miss. The state directory holds a
# credential and the notes are personal, so only their owner may enter either.
RUN mkdir -p /data/mirror /data/state /data/notes \
&& chown -R node:node /data \
&& chmod 700 /data/state /data/notes
# node:alpine ships an unprivileged `node` user.
USER node

143
README.md
View File

@@ -1,8 +1,10 @@
# schulcloud-mcp
Read-only access to a [Schulcloud](https://github.com/hpi-schul-cloud) account —
courses, boards, lessons, tasks and files — for **Claude**, via MCP, and for
**you**, via a CLI that mirrors your coursework to disk.
courses, boards, lessons, tasks and files — plus the timetable from
[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.
@@ -16,8 +18,12 @@ instance, not inferred from the upstream source.
> *"What do I have due this week?"*
> *"Find the material about Verschlüsselung and explain the Caesar cipher worksheet."*
> *"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?"*
Thirteen 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.
| | |
|---|---|
@@ -30,20 +36,80 @@ Thirteen tools, all read-only:
| `list_tasks` | homework across all courses, by due date |
| `get_task` | one task: description, due date, attachments, **and your submission** |
| `list_submissions` | what you handed in, and what is still ungraded |
| `list_files` | files attached to any entity |
| `download_file` | fetch a file and extract its text, or view an image |
| `list_files` | files attached to a board element, topic, task or submission |
| `download_file` | fetch such an attachment and extract its text, or view an image |
| `fs_list` | list a folder of the file manager — Persönliche, Kurs-, Team-, Geteilte Dateien |
| `fs_tree` | everything below a file-manager folder, as a tree |
| `fs_find` | find file-manager files and folders by name |
| `fs_read` | read a file-manager file, extracted like `download_file` |
| `search` | keyword search across everything — **including the text inside PDFs and Office files** |
| `refresh_index` | re-read Schulcloud now, per course or in full |
| `what_changed` | what appeared, changed or vanished since a date |
| `index_status` | how fresh the index is |
| `list_rooms` | rooms ("Räume"), which are a separate space from courses |
| `get_room` | one room: its boards, members and what you may do there |
| `list_classes` | classes ("Klassen") with their teachers, and group membership |
| `list_news` | school and course announcements |
| `get_h5p` | an H5P exercise in full: every question, option and correct answer |
| `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**
files and returns **images inline** for Claude to look at. Image-only PDFs —
scans with no text layer, which are common in this account — are reported as
such rather than as an empty result.
**Attach a course instead of asking for it.** Every course and room is also an
MCP resource (`schulcloud://courses/<id>`, `schulcloud://rooms/<id>`) holding
the same overview `get_course` and `get_room` return. In Claude Code, type `@`
and part of the course name.
**Your own notes are the third source.** Schulcloud has the material and
WebUntis has the schedule; neither has what the teacher actually stressed. Point
`NOTES_DIR` at a directory of Markdown files and `search`, `what_changed` and
all three prompts read it alongside everything else — including a migration path
out of Apple Notes. See [docs/NOTES.md](docs/NOTES.md).
## The notes app
A small web app at `/app`, for writing those notes: a login, the day's notes,
and a settings page for the Schulcloud token. Set `WEB_PASSWORD` to serve it.
One note per school day, one `##` heading per lesson — and **the headings come
from WebUntis**, so opening a day gives you it already laid out with times,
teachers, rooms, cancellations dropped and substitutions marked. Each heading is
indexed as its own lesson, so a search answers "my own note, Deutsch,
18.09.2026" rather than "Friday".
Writing is **formatted, not Markdown**: headings, bold, lists, tick boxes,
quotes, links and tables come from a toolbar, and `MD` shows the Markdown
underneath when you want it. The file on disk stays Markdown either way — that
is what the index reads and what outlives the app.
It saves as you type, keeps a local copy of every keystroke for when the signal
goes, and refuses a save that would overwrite a version it never saw. On a phone
it adds to the home screen and opens standalone.
**Three ready-made prompts**, in German because the school is:
| | |
|---|---|
| `zusammenfassung` *kurs* [*fokus*] | summarise a course or room: topics, tasks, key material, each with its source |
| `pruefungsvorbereitung` *kurs* [*thema*] [*datum*] | prepare for an exam: scope, explanations, practice questions, a study plan |
| `tagesvorbereitung` [*tag*] | prepare a school day: the lessons from WebUntis, what is new in those courses, what is due |
In Claude Code they run as `/mcp__schulcloud__zusammenfassung Mathe_10b`, or
`/mcp__schulcloud__tagesvorbereitung morgen` the evening before.
Claude Code splits arguments on spaces and drops extra words, so join words
with `_` (`Lineare_Funktionen`) and skip an optional argument with `-`. *kurs*
is any unambiguous part of a course or room name, or its id; *tag* takes
`heute`, `morgen`, `übermorgen`, `21.09.2026` or `2026-09-21`.
## The CLI
```bash
@@ -51,10 +117,17 @@ schulcloud login --server https://mcp.example.org --token <token>
schulcloud sync --dry-run # see what would be mirrored
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
```
It talks only to the Pi and holds no Schulcloud credential — see
[docs/CLI.md](docs/CLI.md).
[docs/CLI.md](docs/CLI.md). A fresh token can also be pasted into the server's
`/token` page; either way the server checks it with Schulcloud and swaps it in
without a restart.
## Quick start
@@ -66,8 +139,11 @@ npm run probe # verifies the token and API against the live instan
```
To try it on your own machine — Docker stack, Claude Code, and the CLI — follow
[docs/LOCAL.md](docs/LOCAL.md). To put it on a Pi behind Caddy, see
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
[docs/LOCAL.md](docs/LOCAL.md). To put it on a Pi behind Caddy and a VPS, and
connect claude.ai, follow [docs/PI.md](docs/PI.md);
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) explains the pieces. To add the notes
app to a server that is already running, follow
[docs/DEPLOY-NOTES.md](docs/DEPLOY-NOTES.md).
Getting `TSC_JWT_COOKIE` takes four clicks in DevTools and then lasts 30 days —
provided you close the Schulportal window afterwards. See
@@ -86,6 +162,34 @@ copy is the browser's own session token**, so a Schulportal tab left open will
auto-logout after ~2 hours and revoke this server's token with it. Copy the
token in a private window and close it. See [docs/AUTH.md](docs/AUTH.md).
**The timetable comes from somewhere else.** Schulcloud holds the material for
a lesson but not the lesson: this school's course `times` are empty and it
publishes its schedule in **WebUntis**. So the server reads that too, through
the API the Untis Mobile app uses — which authenticates with a key from Profil →
Freigaben and a time-based code per request, meaning no password, no session to
hold open and nothing that expires monthly. The two systems answer different
halves of the same question, and the German `tagesvorbereitung` prompt is where
they meet: which lessons happen today, what changed in their courses, what is
due. That key *can* write (the app may report an absence), so this side is kept
read-only by an allowlist of five read methods rather than by "GET only". Unset
`UNTIS_*` and none of it exists — the tools are not even offered. See
[docs/AUTH.md](docs/AUTH.md).
**A quiz is one request, not a wizard.** Schulcloud has no quiz of its own, so
an exercise is an H5P element and the board hands over nothing but a content
id. The player then shows one question at a time, which makes a quiz look like
something to step through or scrape — it isn't: the endpoint the player loads
returns the whole exercise, every option and every solution. So `get_h5p`
prints all of it, `get_board` names it with its question count, and `search`
reaches the question text like any other material.
**claude.ai gets a token of its own.** Its connector stores a request header,
so `MCP_CONNECTOR_TOKEN` opens `/mcp` and nothing else — it is refused on
`/api`, which can replace the Schulcloud token — and rotates without touching
Claude Code or the CLI, which keep `MCP_AUTH_TOKEN`. A client that cannot send
headers can use a secret path instead (`MCP_PATH_SECRET`). See
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
**Read-only by construction.** Every method on the API client is a `GET`,
including `api_get`. The endpoint is internet-facing by necessity (Claude's
connectors call it from Anthropic's cloud), so the fact that a leaked token
@@ -118,22 +222,24 @@ bypass, "what's new since…" — are sketched with their trade-offs in
```
src/
core/ client, types, board assembly, crawler, extraction, paths
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 and tools
http/ express app, bearer auth, /api for the CLI
mcp/ MCP server, tools, resources and prompts
http/ express app, bearer auth, /api for the CLI, /app for people
cli/ CLI config, API client, sync engine
bin/ http, stdio and cli entry points
docs/ API findings, auth, deployment, CLI, roadmap
deploy/ Caddyfile snippet
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
```
`core/` knows nothing about MCP, HTTP or the CLI: it holds the Schulcloud client,
the traversal every feature needs, document extraction, and the path
sanitisation that both the server's mirror and the CLI's sync depend on.
the traversal every feature needs, document extraction, the WebUntis client with
its one-time codes, and the path sanitisation that both the server's mirror and
the CLI's sync depend on.
## Development
@@ -144,13 +250,14 @@ npm run probe # check assumptions against the live instance
npm run smoke # full end-to-end: real server, real client, real data
npm run session-diagnose # instrument what actually ends the session (~2.5h)
npm run keepalive-status # is the deployed container holding its session?
npm run publish-image # build amd64 + arm64 and push to registry.mc02.dev — the Pi only pulls
npm run typecheck
```
`npm run smoke` starts the HTTP server, connects a real MCP client over
Streamable HTTP and exercises every tool against the live account — 30 checks
covering the auth gate, the protocol handshake, every content chain, file
extraction, `api_get`'s guard rails and error handling.
Streamable HTTP and exercises every tool against the live account — 91 checks (93 with the index, 9 fewer without a WebUntis key)
covering the auth gate, the connector token and the secret path, the protocol handshake, every content chain, file
extraction, resources and prompts, token replacement, `api_get`'s guard rails and error handling.
## Upstream

View File

@@ -14,7 +14,12 @@ mcp.example.org {
# `schulcloud-mcp` is the Compose service name; Docker's embedded DNS
# resolves it on the shared network. No host port is published. One proxy
# serves both surfaces: /mcp for Claude and /api for the CLI.
# serves all three surfaces: /mcp for Claude, /api for the CLI, and /app for
# the notes app in a browser.
#
# Caddy sets X-Forwarded-Proto on its own, and the app needs it: the hop to
# the container is plain HTTP, so that header is the only evidence the
# session cookie may be marked Secure. Do not strip it.
reverse_proxy schulcloud-mcp:8080 {
# MCP's Streamable HTTP transport keeps a server-sent-events channel
# open for server-initiated messages. Without flush_interval -1 Caddy
@@ -38,9 +43,13 @@ mcp.example.org {
log {
output file /var/log/caddy/schulcloud-mcp.log
format json
# Request URLs are not secrets here (the token is in a header, not the
# path), but the Authorization header must never be written to disk.
# Caddy does not log headers by default; do not add them.
# With MCP_PATH_SECRET set, a request path *is* a credential: claude.ai
# reaches the server at /<secret>/mcp. The filter rewrites that segment
# before the entry is written. The Authorization header must never reach
# disk either; Caddy does not log headers by default do not add them.
format filter {
request>uri regexp ^/[A-Za-z0-9_-]{32,}/mcp /<secret>/mcp
wrap json
}
}
}

View File

@@ -0,0 +1,29 @@
# The Pi's additions to docker-compose.yml — see docs/PI.md.
#
# Selected by one line in the Pi's .env, so plain `docker compose` commands use it:
#
# COMPOSE_FILE=docker-compose.yml:deploy/docker-compose.pi.yml
#
# Setting COMPOSE_FILE also keeps docker-compose.override.yml out. That file is
# for local development only, and Compose would otherwise merge it silently: it
# publishes ports and turns the crawl timer off.
services:
schulcloud-mcp:
# The Pi runs the image published to the registry and never builds one:
# scripts/publish-image.sh builds it for arm64 and amd64 elsewhere. `!reset`
# drops the build section docker-compose.yml declares, so no `docker compose`
# command here can fall back to building (Compose 2.24 or later).
image: registry.mc02.dev/schulcloud-mcp:${SCHULCLOUD_MCP_TAG:-latest}
build: !reset null
environment:
# The bundled Postgres, by name on the private backend network. Overrides
# any DATABASE_URL in .env.
DATABASE_URL: postgresql://schulcloud:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/schulcloud
networks:
caddy:
# Join the network the Pi's Caddy container is already on, so Caddy reaches
# this server by name and no host port is published.
external: true
name: ${CADDY_NETWORK:?set CADDY_NETWORK in .env to the Docker network of your Caddy container}

View File

@@ -1,6 +1,7 @@
# Local development only. Compose merges this automatically; it is not used on
# the Pi, where Caddy reaches the container over the shared Docker network and
# no host port is published.
# Local development only. Compose merges this automatically whenever
# COMPOSE_FILE is unset — which is why the Pi's .env sets COMPOSE_FILE to
# deploy/docker-compose.pi.yml instead (docs/PI.md): there, Caddy reaches the
# container over the shared Docker network and no host port is published.
services:
postgres:
ports:
@@ -12,9 +13,12 @@ services:
environment:
# The bundled postgres service, reachable by name on the compose network.
DATABASE_URL: postgresql://schulcloud:${POSTGRES_PASSWORD:-schulcloud}@postgres:5432/schulcloud
# Crawl on demand while developing rather than every 6 hours.
CRAWL_INTERVAL_MS: 0
# On demand by default while developing. Set CRAWL_INTERVAL_MS in .env to
# crawl on a timer instead — what_changed can only report what happened
# between crawls, so against a real account on-demand leaves it empty.
CRAWL_INTERVAL_MS: ${CRAWL_INTERVAL_MS:-0}
ports:
# Bound to loopback: this exposes the account's data, and the bearer token
# is the only thing in front of it.
- "127.0.0.1:8080:8080"
# MCP_HOST_PORT moves it when 8080 is already taken on this machine.
- "127.0.0.1:${MCP_HOST_PORT:-8080}:8080"

View File

@@ -1,14 +1,13 @@
# Standalone Compose file for the Pi.
# The server and its Postgres.
#
# If you already run Caddy and PostgreSQL from another Compose project, either
# merge the `schulcloud-mcp` service below into that project's file, or keep
# this file separate and attach it to the existing Caddy network — see the
# `networks` block at the bottom and deploy/Caddyfile.snippet.
# Locally, docker-compose.override.yml is merged in automatically (docs/LOCAL.md).
# On the Pi, .env selects deploy/docker-compose.pi.yml instead, which attaches
# the server to the network of the Caddy already running there (docs/PI.md).
services:
# Dev/standalone Postgres. On the Pi, point DATABASE_URL at the existing
# instance instead and remove this service — the schema lives in its own
# database and user, so it coexists with whatever else is already there.
# The index's own Postgres, on a private network with the server. An existing
# instance works too — point DATABASE_URL at it and drop this service — but
# nothing requires sharing one.
postgres:
image: postgres:17-alpine
container_name: schulcloud-mcp-db
@@ -19,8 +18,10 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-schulcloud}
volumes:
- pgdata:/var/lib/postgresql/data
# Only the server talks to Postgres. Keeping it off the Caddy network keeps
# it out of reach of whatever else shares that network on the Pi.
networks:
- caddy
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U schulcloud -d schulcloud"]
interval: 10s
@@ -37,13 +38,22 @@ services:
PORT: 8080
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 is the one thing this server writes; everything else stays
# read-only, so it 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.
@@ -51,6 +61,7 @@ services:
- "8080"
networks:
- caddy
- backend
logging:
driver: json-file
options:
@@ -67,10 +78,13 @@ services:
volumes:
pgdata:
mirror:
state:
notes:
networks:
backend:
caddy:
# Set to true once this joins the network your existing Caddy already uses,
# and change the name to match (`docker network ls` to find it).
# On the Pi, deploy/docker-compose.pi.yml makes this the network your
# existing Caddy already uses (see docs/PI.md).
external: false
name: caddy

View File

@@ -89,10 +89,18 @@ does not exist. The route that returns a course's lessons/tasks/boards is
`GET /api/v3/course-rooms/{roomId}/board`, and its `:roomId` is the *course* id.
Nothing in the naming suggests this.
**`/api/v3/rooms` is a different feature.** "Rooms" are the newer standalone
collaboration spaces, unrelated to courses. On this instance the account has
none, so `GET /api/v3/rooms` returns `{"data":[]}` — which reads like a broken
endpoint but is simply an empty feature.
**`/api/v3/rooms` is a different feature, and the UI's naming hides it.** Rooms
("Räume") are the newer standalone collaboration spaces. The sidebar's *Kurse*
entry links to `/rooms/courses-overview` and lists **courses** (served by
`/api/v3/dashboard` + `/api/v3/courses`), while *Räume* links to `/rooms` and
lists **rooms** — so a url containing `/rooms` identifies neither.
A room holds boards and nothing else: no lessons, no tasks. `GET /rooms`
answers `{"data":[]}` with no `total`, derived from real memberships, so an
empty result means the account is in no rooms — which is also what it looks
like after a teacher deletes a room or revokes access. `GET /rooms/{id}/boards`
does report `isVisible`, unlike the course-page projection, so a room's draft
boards can be identified without trying to open one.
**`limit` maxima are enforced and mis-documented.** The OpenAPI schema says
`maximum: 99`; the runtime validator rejects anything `> 100`. Page at 99 to
@@ -217,6 +225,194 @@ hops and without Etherpad's API key; `core/etherpad.ts` does this. The url is
built from the server's `ETHERPAD__PAD_URI`, so it must be checked against the
instance host before the session cookie is sent to it.
### H5P elements are the quizzes, and one request holds a whole one
An `h5p` element carries nothing but `content: { contentId }`. The content
itself comes from the H5P service, whose API lives under `/api/v3/h5p-editor/`
— not in `docs-json`, and with no document of its own
(`/api/v3/h5p-editor/docs-json` is a 404). The deployment's ingress table routes
`/h5p/player` and `/h5p/editor`, which are the front-end apps, not this API.
- **`GET /api/v3/h5p-editor/params/{contentId}`** returns the JSON the player is
fed: `{ h5p: <metadata>, library, params: { metadata, params } }`. The inner
`params` is the exercise — **every question, every option and which are
correct** — so the player showing one question at a time is a display detail,
not a limit on what can be read. Bearer auth, same as everything else.
- `GET /api/v3/h5p-editor/play/{contentId}` is the same content wrapped in the
player's integration object: 74 KB against 51 KB for the live quiz below,
because it carries script and style lists. `params` is both smaller and
complete, so nothing needs `play`.
- `GET /api/v3/elements/{elementId}` returns a single board element with its
content, which is how an element's `contentId` can be re-read without its
board. (`/api/v3/board/element/{id}` does not exist.)
The **shape inside `params` belongs to the H5P library** the teacher used, which
is where the work is. Verified against the live quiz "Quiz zur formalen
Gestaltung einer Projektdoku" (`H5P.QuestionSet`, 20 questions):
- A `QuestionSet` holds `questions: [{ library, params, subContentId }]`, each
sub-content naming its own library — `H5P.MultiChoice 1.16` here. Any other
main library *is* a single question, with the same `params` shape.
- `H5P.MultiChoice`: `question` and `answers[].text` are HTML,
`answers[].correct` is the solution, `answers[].tipsAndFeedback.tip` a hint,
and `behaviour.singleAnswer` is what makes the player draw radio buttons —
the only honest source for "tick exactly one".
- `H5P.TrueFalse` stores `correct` as the **string** `"true"`/`"false"`, with
the button labels in `l10n`.
- Cloze libraries (`H5P.Blanks`, `H5P.DragText`, `H5P.MarkTheWords`) mark the
solutions inside the text as `*answer:tip*`, alternatives separated by `/`.
- `H5P.SingleChoiceSet` and `H5P.Summary` put the **correct option first** and
let the player shuffle; nothing else marks it.
- Every payload also carries `UI`, `l10n`, `behaviour` and `overallFeedback`
subtrees of button labels and display settings. Anything that harvests text
generically has to skip them, or the exercise reads as "Überprüfen,
Wiederholen, Absenden".
`core/h5p.ts` models the libraries above and harvests the text of anything else
under a label saying so — a teacher's exercise reported as "0 questions" would
be worse than a clumsy rendering of it.
## The file manager ("Dateien") is a third store
Persönliche Dateien, Kurs-Dateien, Team-Dateien and Geteilte Dateien are the
**legacy file system**: a `files` collection with real folders (`isDirectory`,
`parent`, `owner`, `refOwnerModel`), served by the legacy Feathers
`fileStorage` service. It shares nothing with files-storage. Asking
`/api/v3/file/list/school/{school}/courses/{courseId}` answers **0** for a
course whose file manager holds dozens of worksheets — measured: 21 of 26
courses on the live account keep files here, some nothing else.
**Its service is not in the public ingress**, so the only way in is the legacy
client. Verified live:
| Route (legacy client, `jwt` cookie) | Returns | Notes |
|---|---|---|
| `GET /files/my/` , `/files/my/{folder}` | HTML listing | personal root / one folder |
| `GET /files/courses/` | HTML listing | the courses, *as folders* (ids = course ids) |
| `GET /files/courses/{course}` , `/files/courses/{course}/{folder}` | HTML listing | **one** folder segment at any depth |
| `GET /files/teams/…` | HTML listing | same shape as courses |
| `GET /files/shared/` | HTML listing | flat; shared *folders* cannot be opened (no route; the UI's link 404s) |
| `GET /files/signedurl?file={id}&name={name}` | `{"url": …}` | pre-signed S3 url, **another host** (live: `s3.hidrive.strato.com`) |
| `GET /files/permittedDirectories/` | JSON tree | **lists every course with no folders in any** — see below |
| `GET /files/search/?q=` | HTML | **504** on live: an unindexed regex over every file record |
Listings are parsed on the attributes the page's own scripts use:
`data-folder-id` with the name inside `.card-title-directory` (emitted
**unescaped**, `{{{stripOnlyScript name}}}`), and `data-file-id`,
`data-file-name`, `data-file-size`, `data-file-viewer-type` on each
`.card.file`. A blocked file carries `btn-file-danger` and no viewer type. No
dates are rendered.
**`permittedDirectories` is broken for courses.** The directory service's
query matches course folders on `refOwnerModel: 'courses'`; the records say
`'course'`. Live result: 26 courses, 0 folders; personal folders come through.
Listings are the only complete view — which is also what the UI shows.
**Some GET routes write.** `GET /files/share/?file=` mints a share token when
the file has none (`PATCH /fileStorage/shared/{id}`), and
`GET /files/file?…&share=…` grants the caller a permission on the file.
`GET /files/fileModel/{id}/proxy` forwards to the latter. A GET-only client is
therefore *not* read-only against this surface by itself: `core/client.ts`
allows only the listing routes and `/files/signedurl`, by pattern.
**The signed-url service returns its error instead of throwing it**
(`.catch((err) => new Forbidden(err))`), so a refused file is a 200 whose body
has no `url`.
**Course names contain `/`** in real data ("LF07 - FIA24A/B - Sb/Ha",
"FIA24/FIP24 IT LF12"), and a real file is literally called `..docx`. Paths
built from names cannot be split naively; `core/legacy-files.ts` resolves by
trying joined segments, and accepts ids as segments.
Two more, seen while building the local fixture:
- **`getRefOwnerModel(owner)` answers "a course, or else `teams`".** Any owner
id that is not a course — a *user* included — is recorded as a team's. The
upload page never sends an owner for personal files (`data-owner=""`), so the
server defaults to the creator and records `user`; send the user id and every
later permission check dereferences a team that does not exist.
- **The file permission service writes `refOwnerModel`** where the "shared with
me" query reads `refPermModel`, so a share made through it never appears
under Geteilte Dateien. The share-link flow patches `/files/{id}` directly.
## WebUntis is a fourth store, and holds the timetable
Schulcloud's `times` on `/api/v1/courses` are empty for this school, so nothing
in Schulcloud says when a lesson happens, let alone that it was cancelled. That
lives in **WebUntis**, a separate product with its own login. What this server
uses is the API the Untis Mobile app uses, verified against
`ags-erfurt.webuntis.com` on 2026-09-17.
- **One endpoint, JSON-RPC:** `POST /WebUntis/jsonrpc_intern.do?m=<method>&school=<school>&v=i3.2`,
with `{"jsonrpc":"2.0","method":<method>,"params":[{…, "auth":{…}}]}`.
- **`v` is not optional.** Omit it and the call fails with `-8998` carrying a
Java `NullPointerException` from `getParameter`, which reads like a bug in
the request body and is not.
- Errors arrive with **HTTP 200** and an `error` member, so the body has to be
checked before the status.
- A plain `user-agent: schulcloud-mcp` is accepted; there is no need to
impersonate the app.
- **Authentication is a TOTP over a static base32 key**, no password and no
session: `auth: { user, otp, clientTime }` on every call, where `otp` is the
6-digit RFC 6238 code for the key from Profil → Freigaben → Untis Mobile.
- **Send the code as a string.** One in ten starts with a zero, which a JSON
number silently drops. Both shapes are accepted, so only the string is
always right.
- `-8504 bad credentials` = wrong key or user. `-8524 invalid client time` =
the host's clock is off, which is its own failure and worth naming.
- The response sets a `JSESSIONID`, but nothing needs it: each request
authenticates itself, which is why there is no keepalive on this side.
- **Methods that exist** (and are all this server may call — see the allowlist
in `core/untis.ts`): `getUserData2017`, `getTimetable2017`,
`getLessonTopic2017`, `getHomeWork2017`, `getMessagesOfDay2017`.
`getClassregEvents2017` and `getSchoolyears2017` answer "Method not found";
`getPeriodData2017` answers with empty objects for a student.
- **`startDateTime` claims to be UTC and is not.** Lessons come back as
`2026-09-21T08:00Z` and the school's time grid starts at 08:00 local, so the
`Z` is decoration. `new Date(...)` would move every lesson by an hour or two,
twice a year by a different amount; `splitLocal` takes the string apart
instead.
- **A substitution is two periods, not one changed period.** The original turns
up with `is: ["CANCELLED"]` and the replacement beside it with
`is: ["IRREGULAR"]`, same slot, different teacher. `orgId` on an element also
exists (the room moves that way), so both have to be read. Statuses seen on a
real account: `REGULAR`, `CANCELLED`, `IRREGULAR`.
- **`getTimetable2017` carries the whole master data with every answer** —
subjects, teachers with full names, rooms, classes, every holiday of six
school years. There is a `masterDataTimestamp` delta protocol; its removal
semantics are unverified, so this server asks for the full set and caches it
for a few hours.
- **Homework hangs off the periods too.** `getHomeWork2017` filters by the
homework's own dates, so a window ending today shows nothing due tomorrow and
nothing set last month — and the same items appear inline on the timetable's
periods, where they need no second call.
- **`getLessonTopic2017` takes `periodId`, singular.** It answers with
`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
makes `text.info` the most valuable field in the payload rather than a
footnote.
- **A day with no lessons is not necessarily a holiday.** At a vocational school
the weeks spent in the company simply have no periods, and `holidays` says
nothing about them. Reporting "Ferien" there would be wrong; so would an
empty answer.
- **The key can write.** This account's `rights` are `CLASSREGISTER`,
`R_MY_ABSENCES`, `W_OWN_ABSENCE`, `R_OFFICEHOURS` — the mobile API can report
an absence for the user. Nothing here does, and the allowlist is what
guarantees it.
## Re-verifying after an upstream release
`npm run probe` re-checks every assumption above against the live instance and

View File

@@ -77,6 +77,37 @@ and this server cannot end each other.
With no tab attached, the keepalive holds the session to the 30-day hard expiry,
and replacing the token becomes the monthly chore it looked like at first.
## Replacing the token without a restart
`schulcloud token set` (paste at a hidden prompt, or pipe it in) and the
server's `/token` page both send a fresh token to `PUT /api/token`, behind the
bearer check. `core/session-token.ts` then:
1. **Cleans the paste.** A bare value, `jwt=…; Path=/`, quotes and newlines all
work.
2. **Checks before it swaps.** A malformed or expired token is refused without
asking Schulcloud; otherwise `GET /api/v3/me` must succeed *with the new
token*, and for the same `userId` the current one carries. A refused token
changes nothing. Switching accounts stays a deliberate act — change
`TSC_JWT_COOKIE` and restart.
3. **Swaps it in place.** Every request reads `config.jwt` at the moment it is
sent, so the next one uses the new token; nothing caches a copy.
4. **Restarts the keepalive**, which stopped for good on a 401. Its pings carry
a generation number, so one still in flight with the old token cannot stop
the new cycle when its 401 arrives.
5. **Saves it** to `STATE_DIR` (0600, written beside itself and renamed). At
startup the newer of the saved token and `TSC_JWT_COOKIE` wins, by `exp`
unless they belong to different accounts, when the environment does.
The token appears in no log line and no response; `/api/token` reports only
the expiry, where the token came from, and the keepalive's state. The claims
are decoded, never verified — Schulcloud verifies, this only reads dates.
The cookie is **HttpOnly**, so no script — no bookmarklet, no page on another
origin — can read it out of the browser. The DevTools copy is the one manual
step, and it cannot be automated away short of a headless browser holding the
Schulportal password (see below).
### There is no longer window available
`config/default.schema.json` documents `JWT_EXTENDED_TIMEOUT_SECONDS`
@@ -131,6 +162,38 @@ says so immediately:
[schulcloud-mcp] keepalive: token rejected (401). The session is gone …
```
## WebUntis: a second credential, of a different kind
The timetable is not in Schulcloud, so the server talks to WebUntis as well —
and that side authenticates far more comfortably. In WebUntis, open **Profil →
Freigaben → Untis Mobile → QR-Code**. The dialog shows four values, which go
into `.env` as `UNTIS_SERVER` (its "Url"), `UNTIS_SCHOOL`, `UNTIS_USER` and
`UNTIS_SECRET` (its "Schlüssel"). The school number shown there is not used.
Why this is the right credential for a server:
- **No password.** The key is what the Untis Mobile app is given, and it works
even where the WebUntis login goes through the school's SSO.
- **Nothing to keep alive.** Every request carries a fresh time-based code
derived from the key, so there is no session to refresh and nothing that dies
when the Pi is off for a day. Unlike the Schulcloud token, this needs no
monthly chore: the key stays valid until you replace it.
- **Revocable on its own.** Generating a new key in that dialog invalidates the
old one, and it has nothing to do with your password.
Two things to know:
- **The host's clock matters.** A time-based code from a drifting clock is
refused with `-8524 invalid client time`, which the tools report as such. Any
Pi with working NTP is fine.
- **The key is not read-only — the server is.** It can do what the app can, and
on this account that includes reporting an absence (`W_OWN_ABSENCE`). Untis'
API is JSON-RPC, where reads are POSTs too, so "GET only" cannot be the
guarantee here as it is for Schulcloud. Instead `core/untis.ts` carries an
allowlist of five read methods and refuses everything else; `test/untis.test.ts`
holds it to that. Replacing `UNTIS_SECRET` needs a restart — there is no live
swap for it, because it does not expire.
## Why not username + password
The instance's login redirects to Keycloak (realm `TIS`) with a `redirect_uri`
@@ -145,30 +208,93 @@ approach is the right trade: one manual step a month against re-implementing an
OAuth client whose secret we cannot hold. If that monthly step ever becomes
unacceptable, the honest options are a service account issued by the school's
IDM, or driving the Keycloak login with a headless browser — not a
reimplementation of the code exchange. If this ever needs to be unattended, the honest options are a service
account issued by the school's IDM, or a headless browser login — not a
reimplementation of the Keycloak exchange.
reimplementation of the code exchange. A headless browser would have to hold
the Schulportal password, which unlocks far more than this account's school
files, so it is not done here.
## Protecting this server's own endpoint
Distinct from the above, and just as important. The MCP endpoint is reachable
from the public internet by construction: Claude's connectors call it from
Anthropic's cloud, not from your machine. It is protected by `MCP_AUTH_TOKEN`,
a shared secret checked in constant time on every `/mcp` request
(`src/http/auth.ts`), accepted as either `Authorization: Bearer …` or
`X-Api-Key`. `/healthz` is deliberately open and reveals nothing.
a shared secret checked in constant time on every `/mcp` and `/api` request
(`src/http/auth.ts`). It is accepted as `Authorization: Bearer …`, as a bare
`Authorization` value — claude.ai sends a header exactly as typed — or as
`X-Api-Key` or `X-Auth-Token`. `/healthz` is deliberately open and reveals
nothing.
Generate one with `openssl rand -hex 32`. If it is unset the server logs a loud
warning and serves unauthenticated — only acceptable bound to localhost.
Rotating it: change `MCP_AUTH_TOKEN` in `.env`, restart the container, update
the connector in Claude. Nothing else stores it.
Rotating it: change `MCP_AUTH_TOKEN` in `.env`, recreate the container, and
update Claude Code and the CLI (`schulcloud login`). Nothing else stores it.
### The connector token, for claude.ai
claude.ai sends a request header whose value it stores, so its token is a
credential held by a third party. `MCP_CONNECTOR_TOKEN` gives it one of its
own: accepted on `/mcp` alone, refused on `/api` — which can replace the
Schulcloud token and stream the file mirror — and rotated without touching
Claude Code or the CLI. `config.ts` requires at least 32 characters, a value
different from `MCP_AUTH_TOKEN`, and `MCP_AUTH_TOKEN` itself, so it can never
leave `/api` unguarded; its errors never echo a value. All accepted tokens are
compared in full, so the timing does not tell which one matched.
### The secret path, for clients without request headers
For a client that can send no header, `MCP_PATH_SECRET` opens another way in:
`/<secret>/mcp`, with no token at all. The path is the credential there. `http/auth.ts` compares it in constant time and answers a
wrong one with the same 404 as any unknown path; `config.ts` insists on at
least 32 URL-safe characters and never echoes the value; the server never logs
request paths; and `deploy/Caddyfile.snippet` rewrites the segment before an
access log entry is written. Rotating it means a new value, a recreated
container, and re-adding the connector. Prefer the connector token wherever a
header can be sent: a URL is copied into more places than a header is.
### The app password, for a person
`WEB_PASSWORD` is unlike every other credential here: it is typed by a human, on
a phone, in a lesson. That single fact drives its whole design.
- **It is a passphrase, not a token.** `config.ts` insists on 12 characters and
nothing else; demanding punctuation would buy little next to length, and the
failure mode of a fussy rule is a shorter password, not a better one.
- **It is never stored in the clear.** scrypt (N=16384) at startup; a login
hashes the attempt and compares in constant time. The error states the rule
and never echoes the value.
- **Logins are rate-limited per address**, eight failures in fifteen minutes.
Not optional: a password is guessable in a way a 32-byte token is not, and the
scrypt cost that makes guessing expensive is itself a denial-of-service vector
without a limiter in front of it.
- **The session is a signed cookie**, `HttpOnly` and `SameSite=Strict` — the
latter standing in for CSRF tokens, since nothing links into the app from
anywhere else. 30 days, because the alternative is a login screen at the start
of a lesson.
- **The signing key is derived from the password**, so changing it invalidates
every session that exists. No second secret, nothing to store, and the
behaviour anyone changing a password already expects.
- **The cookie opens `/api` and not `/mcp`.** A session *is* the user, and the
app is built on `/api` — but nothing in a browser speaks MCP, and a surface
that is not needed is not offered.
Unset, the app is not served at all. A login screen that no password can open is
worse than no page, because it looks like a way in.
## Blast radius
Every path in this server is a `GET`, including the `api_get` escape hatch,
which rejects anything not starting with `/api/` and anything carrying a scheme
or host. Someone who obtained both the endpoint URL and `MCP_AUTH_TOKEN` could
read this account's Schulcloud data; they could not post, submit, delete, or
otherwise act as the user. Keep it that way — adding a single write tool would
change that property entirely.
or host. Someone who obtained both the endpoint URL and `MCP_AUTH_TOKEN` — or
the connector token, the secret MCP path, or the app password — could read this
account's Schulcloud data; they could not
post, submit, delete, or otherwise act as the user. With `MCP_AUTH_TOKEN` they
could also call `PUT /api/token`, but it accepts only a live token for the same
account, so the most it can do is hand the server a session the owner already
has. Keep it that way — adding a single write tool would change that property
entirely.
The app password and `MCP_AUTH_TOKEN` additionally reach the notes: they can
read, write and overwrite files under `NOTES_DIR`, and nothing outside it —
`safeComponent` and `resolveWithin` are what make that a property rather than a
hope. That is the only write anywhere in this server, and it touches the user's
own files, never Schulcloud. `NOTES_READONLY=1` removes even that.

View File

@@ -39,13 +39,114 @@ 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.
`--course` accepts a **course or a room id** — rooms ("Räume") are mirrored
alongside courses, with their files under the room's name rather than a course's.
`refresh` asks the server to re-read Schulcloud. Pass `--course` when you know
what changed: that is a handful of requests, where a full re-crawl reads every
course. The server refuses a repeat within a minute unless you pass `--force`.
A full re-crawl can take many minutes — the first one downloads every file,
file-manager folders included — so `refresh` starts it and then polls the
server's status, printing a note every half minute, rather than holding one
request open (which Node's fetch abandons after five minutes).
### The server's Schulcloud token (`token`)
```
schulcloud token when it expires, and whether the session is alive
schulcloud token set hand the server a fresh one
```
The monthly chore, with no restart and no `.env` edit:
1. Open a **private window** and log in to Schulcloud.
2. DevTools → Application (Firefox: Storage) → Cookies → `jwt`: copy the value.
3. `schulcloud token set` and paste it at the prompt. The input is hidden.
4. **Close the private window.** Left open, it logs the token out about two
hours after login (docs/AUTH.md).
Piping works too — `wl-paste | schulcloud token set` — and a pasted cookie
line such as `jwt=…; Path=/` is cleaned up. The token is never a command-line
argument, so it cannot end up in shell history.
The server checks the token with Schulcloud before swapping it in, so a bad
paste changes nothing. It refuses a token that is malformed, expired, already
logged out, or for a different account. A replacement is saved on the server
(`STATE_DIR`), so a restart keeps it, and the keepalive picks it up at once.
The same form lives at `https://<server>/token` for when no terminal is at hand.
The cookie is HttpOnly, so no bookmarklet can read it for you; the DevTools
copy is the step that remains.
### The file manager (`fs`)
The Schulcloud file manager ("Dateien") — Persönliche, Kurs-, Team- and
Geteilte Dateien — browsed like a filesystem, live:
```
schulcloud fs ls [path] [--long]
schulcloud fs tree [path] [--depth <n>] [--max-folders <n>]
schulcloud fs find <name> [--path <path>] [--type file|folder] [--long]
schulcloud fs get <path> [--out <path>] [--force] [--jobs <n>]
```
```console
$ schulcloud fs ls /courses
$ schulcloud fs tree "/courses/FIA24B - SK (Rh)"
$ schulcloud fs find "*Erben*" --path /courses
$ schulcloud fs get "/courses/FIA24B - SK (Rh)/02_Erbrecht" --out ~/Erbrecht
```
The tree is `/my`, `/courses/<course>`, `/teams/<team>` and `/shared`; the
German names ("/Kurs-Dateien") work too. Names may contain `/` — course names
often do — and still resolve; any segment may also be an id from `--long`.
`fs find` matches any part of a name, or, given `*` or `?`, the whole name as
`find -name` does. `fs get` on a folder downloads everything below it, keeps
the structure, and skips files already present at the same size — so re-running
it resumes. Each folder is one page load on the server, so large trees take a
while.
`sync` mirrors these files too, under `<course>/Kurs-Dateien/…`,
`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

270
docs/DEPLOY-NOTES.md Normal file
View File

@@ -0,0 +1,270 @@
# Rolling out the notes app
A runbook for putting the notes feature and the `/app` web app onto a
deployment that is already running. [PI.md](PI.md) is the first-time setup;
this is the upgrade, and it assumes the server is live and healthy.
Read [NOTES.md](NOTES.md) first if you have not: **step 2 is a decision you
should not make twice**, because changing it later means moving files by hand.
## What arrives
| | |
|---|---|
| **Your own lesson notes** | A directory of Markdown files the server reads, indexes and searches beside Schulcloud and WebUntis. Three tools: `list_notes`, `get_note`, `add_note`. |
| **The app at `/app`** | A login, a day-at-a-time notes editor with a formatting toolbar, and a settings page that replaces the Schulcloud token. Only served when `WEB_PASSWORD` is set. |
| **The WebUntis class register** | `untis_lesson_topics` now takes a subject as well as a period id, and `UNTIS_HISTORY_DAYS` of "what was actually taught" goes into the search index. |
Nothing here changes Schulcloud or WebUntis: both stay read-only. The notes
directory is the only thing this server writes to, and it is yours.
## Before you start
- **Ten minutes**, plus however long a full crawl takes (the first one with a
class register adds a few dozen WebUntis requests; it runs in the background).
- **No Caddy change.** `/app` is served by the same container on the same port,
and the snippet already proxies everything. One line was added to
`deploy/Caddyfile.snippet` as a comment about `X-Forwarded-Proto`; if your
site block predates it, check that nothing strips that header — the session
cookie's `Secure` flag depends on it.
- **No database migration.** Notes and class-register entries are new node kinds
in a column that is plain `TEXT`.
- **No compose change on the Pi.** `deploy/docker-compose.pi.yml` overrides only
the image, the database URL and the network, so the `notes` volume and
`NOTES_DIR=/data/notes` come from `docker-compose.yml` unchanged.
## 1. Publish the image
On a development machine, from a clean checkout of `main`:
```bash
npm run publish-image
```
It builds arm64 and amd64 and pushes `latest` plus the commit id. If you pin
`SCHULCLOUD_MCP_TAG`, note the short commit it prints — you need it in step 3.
## 2. Decide where the notes live
**Do this before anything writes a note.** Both options work; moving between
them afterwards means moving files.
**A — the volume (default, nothing to do).** `docker-compose.yml` already
declares a `notes` volume at `/data/notes`. The app and the CLI write to it,
`add_note` writes to it, and that is the whole story. Choose this if you will
write notes in the app and nowhere else.
**B — a directory you sync.** Choose this to *also* write notes from a phone or
a laptop in an editor — Obsidian, iA Writer, a git repo. Edit
`docker-compose.yml` on the Pi:
```yaml
# under schulcloud-mcp:
volumes:
- mirror:/data/mirror
- state:/data/state
- /home/pi/Notizen:/data/notes # was: notes:/data/notes
```
```bash
mkdir -p /home/pi/Notizen
```
The container runs unprivileged and `read_only`, so that directory must be
writable by the container's user — if the logs show `EACCES` for `/data/notes`,
see Troubleshooting. Then point Syncthing, Nextcloud or `git` at it. The server
does not care which; new files are picked up by the next full crawl.
## 3. Configure
On the Pi, in `/opt/schulcloud-mcp`:
```bash
cd /opt/schulcloud-mcp
git pull
```
Add the app password to `.env`. It is the only credential here a person types,
so it is a passphrase rather than a token — **at least 12 characters, and three
or four words is the right shape**:
```bash
cat >> .env <<'EOF'
# --- the notes app ---
WEB_PASSWORD=change-this-to-three-or-four-words
EOF
```
Optional, on the same pass:
| Setting | |
|---|---|
| `UNTIS_HISTORY_DAYS` | How far back to index the class register. Default 180; `0` turns it off. Only does anything with `UNTIS_*` configured. |
| `NOTES_READONLY=1` | Refuse every write. `list_notes` and `get_note` still work, `add_note` and the app's save do not. Right when the notes are synced in and should have exactly one writer. |
| `SCHULCLOUD_MCP_TAG` | If you pin images, set it to the commit from step 1. |
**Put `WEB_PASSWORD` in your password manager now.** The server never prints it,
and changing it logs out every session.
## 4. Start it
```bash
docker compose pull
docker compose up -d
docker compose logs --tail 20 schulcloud-mcp
```
The startup line names what is on. With everything configured it ends
`… keepalive every 30min, index every 6h`; the app and the notes are not named
there, so verify them in the next step rather than reading the log for them.
## 5. Verify
From anywhere:
```bash
curl -s https://mcp.example.org/healthz
# {"status":"ok","sessions":0,"index":"on"}
curl -s -o /dev/null -w '%{http_code}\n' https://mcp.example.org/app/
# 200 ← the app is served; 404 means WEB_PASSWORD is not set
```
Then open `https://mcp.example.org/app/` in a browser and log in.
- **Notizen** should show today, and — if WebUntis is configured — today's
lessons as headings, with times, teacher and room. "Kein Unterricht an diesem
Tag" on a company-phase week or a weekend is correct, not a fault.
- Type a line and wait two seconds. The status line should read
**Gespeichert HH:MM**.
- **Einstellungen** should show the Schulcloud token's remaining days and the
index's state.
On a phone, add it to the home screen — it has a manifest and opens standalone.
Check the file landed where you meant it to:
```bash
docker compose exec schulcloud-mcp ls -R /data/notes
# 2026/2026-09-19.md
```
## 6. Bring the old notes in
If you have notes in Apple Notes, migrate them now — see
[NOTES.md](NOTES.md#migrating-out-of-apple-notes). Briefly, on the Mac:
```bash
# --folder takes that folder and everything under it; leave it off for all notes
osascript -l JavaScript scripts/export-apple-notes.js --folder Schule > notes.ndjson
wc -l notes.ndjson # one line per note
schulcloud note import notes.ndjson --dry-run # look first
schulcloud note import notes.ndjson
```
The export runs on the Mac; the import does not have to. The CLI talks to the
server over HTTP, so copying the `.ndjson` to whatever machine already has the
CLI configured is the shorter path.
Import **once**. Re-running creates second copies, because the importer cannot
tell an edited note from a new one with the same title.
## 7. Index them
Notes and the class register are only read by a **full** crawl. One runs on the
timer (`CRAWL_INTERVAL_MS`, six hours by default), or force one now:
```bash
schulcloud refresh --force
```
Expect it to take minutes; the CLI polls and prints progress. When it finishes:
```bash
schulcloud status
```
Then ask Claude something only the new sources can answer — *"what did I write
down in Deutsch last week?"* or *"what did we actually cover in LF07 this
term?"* — and check the answer names your note or the class register as its
source.
## Backups, which now matter more
**The notes are the only irreplaceable thing this server holds.** Everything
else it stores is a copy of something upstream; a note you took in a lesson is
not, and nothing can rebuild it.
| What | Needed? |
|---|---|
| `.env` | **Yes** — every secret, `WEB_PASSWORD` included. Encrypted only. |
| **`schulcloud-mcp_notes`** (option A) | **Yes. Nothing can regenerate these.** |
| **Your synced directory** (option B) | **Yes**, unless the sync tool already keeps versioned copies elsewhere — and check that it does, rather than assuming. |
| Postgres | Optional: a crawl rebuilds it. |
| `schulcloud-mcp_mirror` | No — re-downloaded by the next crawl. |
| `schulcloud-mcp_state` | No — a replaced token, expiring within 30 days anyway. |
With the volume (option A):
```bash
docker run --rm -v schulcloud-mcp_notes:/notes:ro -v "$PWD":/out alpine \
tar czf /out/notizen-$(date +%F).tar.gz -C /notes .
```
They are small — a school year of notes is a few megabytes — so back them up
often and keep the old copies.
## Rolling back
The feature adds no migration and no incompatible state, so going back is the
ordinary downgrade:
```bash
# in .env
SCHULCLOUD_MCP_TAG=<previous commit>
```
```bash
docker compose pull && docker compose up -d
```
The old image ignores `WEB_PASSWORD` and `NOTES_DIR` and serves no `/app`. **The
notes volume is untouched** — the files stay, and the newer image picks them up
again unchanged. The only thing lost while rolled back is the ability to read or
write them.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| `/app/` answers 404 | `WEB_PASSWORD` not set, or the container not recreated since it was | `grep ^WEB_PASSWORD= .env`, then `docker compose up -d --force-recreate schulcloud-mcp` |
| The server refuses to start, log names `WEB_PASSWORD` | Shorter than 12 characters | Use a longer passphrase |
| Login says the password is wrong, and it is not | `.env` is read at container creation | `docker compose up -d --force-recreate schulcloud-mcp` |
| Logged out constantly, or the login "does nothing" | The session cookie is marked `Secure` and the connection is not HTTPS, or `X-Forwarded-Proto` is stripped | Reach it over HTTPS; check the Caddy site does not strip that header |
| `Zu viele Fehlversuche` | The per-address rate limiter, eight failures in fifteen minutes | Wait it out; it is doing its job |
| The editor says the server keeps no notes | `NOTES_DIR` unset on the server | It is set by `docker-compose.yml`; check `COMPOSE_FILE` in `.env` still lists it first |
| `Der Server nimmt keine Änderungen an` | `NOTES_READONLY` is on | Remove it and recreate the container |
| Saves refused as a conflict, repeatedly | The note is being changed elsewhere — a sync tool, another device | Choose a version in the banner; if a sync tool keeps rewriting the file, it is fighting the app |
| `EACCES` for `/data/notes` in the logs | A bind-mounted directory the container's user cannot write | `sudo chown -R 1000:1000 /home/pi/Notizen` (match the image's user), then recreate |
| The toolbar is there, the text stays plain | The browser blocked `editor.js` or `markdown.js` | Check the console; both must be served from `/app/`, and `app.js` must load as `type="module"` |
| A note opens in the Markdown view by itself, with a hint | It holds formatting the formatted view cannot keep unchanged | Nothing is wrong and nothing was lost; edit it there, or simplify the note |
| Notes exist but `search` cannot find them | Only a full crawl reads them | `schulcloud refresh --force` |
| `search` finds a day note but names no subject | The lesson headings were rewritten past recognition | Keep `## 1. Deutsch …`; the leading number and the subject are what the index reads |
| `untis_lesson_topics` with a subject finds nothing | The subject code differs from what you typed | Check it against `untis_timetable`; the register uses the school's own codes |
## Security notes for this rollout
- `WEB_PASSWORD` is the first credential here that a human types, so the first
that can be guessed. It is hashed with scrypt at startup and never stored,
compared or logged in the clear, and failed logins are rate-limited per
address — but **length is what actually protects it**.
- The session cookie opens `/api`, which can read your coursework and replace
the Schulcloud token. It does not open `/mcp`. Treat a login on a shared
device as you would treat the token.
- Changing `WEB_PASSWORD` invalidates every session, because the signing key is
derived from it. That is the revocation mechanism: change it, recreate the
container, log in again.
- The write surface is bounded to `NOTES_DIR` by `safeComponent` and
`resolveWithin` — the same two functions that stop a hostile Schulcloud
filename escaping the file mirror. Schulcloud and WebUntis remain read-only.

View File

@@ -1,5 +1,8 @@
# Deployment
This page explains the moving parts and why each is there. For the steps in
order, on the Pi, follow [PI.md](PI.md).
## The shape of it
```
@@ -13,13 +16,21 @@ schulcloud CLI ─────┘ └─ Caddy
└──▶ schulcloud-thueringen.de
```
Both front ends use the same hostname and the same bearer token. `/mcp` speaks
MCP; `/api` serves the CLI's manifest, file bytes and re-crawl requests.
Both front ends use the same hostname. `/mcp` speaks MCP; `/api` serves the
CLI's manifest, file bytes, re-crawl requests and token replacement; `/token` is
a page for pasting a fresh Schulcloud token.
Claude's custom connectors call the endpoint from Anthropic's cloud, so it must
be publicly reachable over real TLS — a localhost tunnel or self-signed cert
will not do. The VPS provides the public address; Caddy on the Pi terminates
TLS and obtains the certificate.
Claude's custom connectors call the endpoint from Anthropic's cloud
(`160.79.104.0/21`), so it must be publicly reachable over real TLS — a
localhost tunnel or self-signed cert will not do. The VPS provides the public
address; Caddy on the Pi terminates TLS and obtains the certificate. **Keep the
VPS forwarding raw TCP** rather than terminating TLS itself: then it never sees
a request, whose header or path carries a credential (below).
**The Pi must stay up.** More than two hours offline ends the Schulcloud session
however long the token has left — a laptop that sleeps overnight loses it every
night. A replacement token fixes that without a restart (see *Replacing the
Schulcloud token*), but an always-on host is what avoids needing one.
The container publishes no host port. Caddy reaches it over the shared Docker
network, so the only way in from the internet is through Caddy and then through
@@ -27,22 +38,21 @@ this server's bearer check.
## First deploy
[PI.md](PI.md) steps 15: log in to `registry.mc02.dev`, clone to
`/opt/schulcloud-mcp` for the compose files, write `.env` (the Pi's
`COMPOSE_FILE`, `CADDY_NETWORK` and `POSTGRES_PASSWORD`, generated secrets, the
first Schulcloud token), then:
```bash
git clone <this repo> /opt/schulcloud-mcp
cd /opt/schulcloud-mcp
cp .env.example .env
# Fill in TSC_URL and TSC_JWT_COOKIE (see docs/AUTH.md), then:
openssl rand -hex 32 # → MCP_AUTH_TOKEN
docker compose up -d --build
docker compose pull
docker compose up -d
docker compose logs -f schulcloud-mcp
```
Expect:
```
[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://… , auth enabled, keepalive every 30min
[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://… , auth enabled (plus secret MCP path), token from environment, 29 day(s) left, keepalive every 30min, index every 6h
```
`auth DISABLED` there means `MCP_AUTH_TOKEN` is empty — fix it before exposing
@@ -52,39 +62,29 @@ will run but every tool will fail.
## Joining the existing Caddy
The Pi already runs Caddy and PostgreSQL in a Compose project. This server needs
neither a database nor its own Caddy — only a network it shares with the
existing one.
The Pi already runs Caddy in a container. This server needs no Caddy of its own
— only to sit on the same Docker network, so Caddy reaches it by name and no
host port is published.
Find the network Caddy is on:
```bash
docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' <caddy-container>
```
Then in `docker-compose.yml`, set that name and mark it external:
```yaml
networks:
caddy:
external: true
name: <the network name you just found>
```
That is what `deploy/docker-compose.pi.yml` does, and the Pi's `.env` selects it
with `COMPOSE_FILE=docker-compose.yml:deploy/docker-compose.pi.yml`, so plain
`docker compose` commands pick it up and nothing tracked needs editing. It
declares the Caddy network external under the name in `CADDY_NETWORK`. Setting
`COMPOSE_FILE` also matters for what it leaves out: without it, Compose merges
`docker-compose.override.yml`, which is for local development and publishes
ports.
### Postgres
The index needs a database. On the Pi, use the existing PostgreSQL rather than
the container in `docker-compose.yml` — create a database and user for it:
The index gets its own Postgres container, on a network shared with this server
and nothing else — not with Caddy, and not with whatever else is on Caddy's
network. The Pi file builds `DATABASE_URL` from `POSTGRES_PASSWORD`. Migrations
run at startup; the first creates `pg_trgm`.
```sql
CREATE USER schulcloud WITH PASSWORD '';
CREATE DATABASE schulcloud OWNER schulcloud;
```
Then set `DATABASE_URL` in `.env` and delete the `postgres` service from the
compose file. Migrations run automatically at startup; `pg_trgm` is created by
the first migration, which needs the database owner to be able to
`CREATE EXTENSION`.
An existing Postgres works as well: create a database and a user that owns it
(it must be able to `CREATE EXTENSION`), point `DATABASE_URL` at it, and remove
the `postgres` service and the `depends_on` that names it. Nothing requires
sharing one.
Without `DATABASE_URL` the server still runs: search crawls live on every call
and `/api` returns `503`. The startup log says which mode it is in.
@@ -105,6 +105,9 @@ Two settings in that snippet matter and are easy to miss:
connector hangs with no error.
- **`read_timeout`/`write_timeout` of 300s** — a `search` call walks every
course and can take tens of seconds. Caddy's defaults will cut it off.
- **The `format filter` in `log`** — rewrites `/<secret>/mcp` before an access
log entry is written. Without it every claude.ai request writes the secret to
disk. Verified against Caddy 2.11: the entry reads `"uri":"/<secret>/mcp"`.
## Ports and DNS
@@ -122,6 +125,10 @@ curl -s https://mcp.example.org/healthz
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.org/mcp \
-H 'content-type: application/json' -d '{}'
# 401 ← the bearer check is live
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.org/$(openssl rand -hex 32)/mcp \
-H 'content-type: application/json' -d '{}'
# 404 ← a wrong path secret looks like any unknown path
```
If `/healthz` answers but `/mcp` returns 401 with a correct token, check that
@@ -130,15 +137,134 @@ newline from a copy-paste.
## Connecting Claude
1. claude.ai **Settings → Connectors → Add custom connector**.
2. URL: `https://mcp.example.org/mcp`
3. Under **Advanced settings**, add the bearer token as an authorization
header. If your organisation has no header-auth field, the server also
accepts the token as `X-Api-Key`.
4. Enable the connector in a conversation via **+ → Add connectors**.
### claude.ai — a request header with a token of its own
Ask *"which courses am I in?"* as a first check — that exercises auth, the
Schulcloud token and the API in one call.
claude.ai's *Add custom connector* dialog asks for a name and a URL first. Its
authentication settings, **Request headers** among them, appear on the next
step, once it has probed the URL.
1. Put `MCP_CONNECTOR_TOKEN=<openssl rand -hex 32>` in `.env` and recreate the
container: `docker compose up -d --force-recreate schulcloud-mcp`. The
startup line then says `(plus connector token)`.
2. claude.ai → **Customize → Connectors → Add custom connector**. Name it, and
give the URL `https://mcp.example.org/mcp`.
3. On the next step keep **No sign-in**, which is what Claude detects, and add a
request header: name `authorization`, value `Bearer <MCP_CONNECTOR_TOKEN>`,
space included. A bare token, or the header `x-api-key`, works too.
4. Enable it in a conversation via **+ → Connectors**.
Ask *"which courses am I in?"* as a first check — that exercises the header,
the Schulcloud token and the API in one call.
**Why a token of its own.** claude.ai stores the header value, so the token is
a credential held by a third party. `MCP_CONNECTOR_TOKEN` opens `/mcp`, the
read-only tools, and is refused on `/api`, which can replace the Schulcloud
token and stream the file mirror. It also rotates alone: set a new value,
recreate the container, then remove the connector and add it again, because
claude.ai cannot edit a stored header. Claude Code and the CLI are unaffected.
### Without request headers — a secret path
For a client that cannot send a header, the server can serve MCP at a path
that is itself the secret:
1. Put `MCP_PATH_SECRET=<openssl rand -hex 32>` in `.env` and recreate the
container. The startup line then says `(plus secret MCP path)` — never the
secret itself.
2. Give the client the URL `https://mcp.example.org/<secret>/mcp`, with no
header and no sign-in.
**Know what this trades away.** The URL is now the credential, and Anthropic's
connector documentation calls credentials in URLs a security vulnerability,
because URLs end up in logs. This deployment keeps it out of its own: the
server never logs request paths, the Caddy snippet rewrites the segment to
`<secret>` in the access log, and a VPS forwarding raw TCP never sees it.
Caddy's *error* log can still name the path if the container is down while
claude.ai calls, and claude.ai stores the URL in its connector settings. There
is no revocation short of a new secret: change `MCP_PATH_SECRET`, recreate the
container, and add the connector again. Anyone holding the URL can read — never
change — the account. A wrong secret answers 404, like any unknown path.
### Claude Code and the CLI — the bearer token
Both can send a header, so they keep using `MCP_AUTH_TOKEN` on the plain `/mcp`
and `/api`:
```bash
claude mcp add --transport http --scope user schulcloud https://mcp.example.org/mcp \
--header "Authorization: Bearer <MCP_AUTH_TOKEN>"
schulcloud login --server https://mcp.example.org --token <MCP_AUTH_TOKEN>
```
## The WebUntis key
The timetable comes from WebUntis, which needs four values in `.env`
`UNTIS_SERVER`, `UNTIS_SCHOOL`, `UNTIS_USER`, `UNTIS_SECRET` — all from WebUntis
→ Profil → Freigaben → Untis Mobile → QR-Code. With them the server offers the
`untis_*` tools and the `tagesvorbereitung` prompt; without them it does not,
and nothing else changes.
Operationally it is the easy credential: no password, it survives the container
being off, and it expires only when you generate a new key. Two consequences
for a deployment:
- **Recreate the container after changing it** (`docker compose up -d
--force-recreate schulcloud-mcp`) — `env_file` is read at creation, and unlike
the Schulcloud token there is no live swap, because there is no monthly chore
to spare anyone.
- **Keep the host's clock synchronised.** Requests are signed with a
time-based code; a drifting clock is refused with "invalid client time", which
the tools report in those words.
## The notes app
Rolling this onto a server that is already running has its own runbook:
[DEPLOY-NOTES.md](DEPLOY-NOTES.md).
Set `WEB_PASSWORD` in `.env` and the server offers `/app`: the notes editor and
a settings page. Unset, it is not served at all, and nothing else changes.
```
https://mcp.example.org/app/
```
Three things worth knowing for a deployment:
- **Recreate the container after changing the password** (`docker compose up -d
--force-recreate schulcloud-mcp`) — `env_file` is read at creation. Changing
it also logs out every session, by design: the session signing key is derived
from it.
- **Notes need somewhere to live.** `docker-compose.yml` sets
`NOTES_DIR=/data/notes` with a volume of its own. To write the notes from a
phone through a sync tool as well as through the app, bind-mount a real
directory there instead — see [NOTES.md](NOTES.md).
- **The app is a way to replace the Schulcloud token**, which is the next
section, and the more comfortable one when the expiry catches you away from a
terminal.
`/token` still exists and still works. It is the fallback for a deployment with
no `WEB_PASSWORD`, and it is unchanged.
## Replacing the Schulcloud token
The token lasts 30 days at most and can only come from a browser login (see
docs/AUTH.md), so this is the monthly chore — but it no longer needs a restart
or an `.env` edit:
1. Log in to Schulcloud in a **private window** and copy the `jwt` cookie's
value (DevTools → Application → Cookies).
2. Either run `schulcloud token set` and paste it, open the app's
**Einstellungen** tab, or open `https://mcp.example.org/token` and paste it
together with `MCP_AUTH_TOKEN`.
3. **Close the private window.**
The server checks the token with Schulcloud first — right account, not
expired, not logged out — then swaps it in, restarts the keepalive and saves it
to the `state` volume (`STATE_DIR=/data/state`), so a restart keeps it. At
startup the newer of the saved token and `TSC_JWT_COOKIE` wins, unless the two
are for different accounts: changing `TSC_JWT_COOKIE` is how you switch
accounts. `schulcloud token` shows the days left; from a week before expiry the
log, `whoami` and the CLI warn about it.
## Running it locally instead
@@ -162,15 +288,44 @@ use stdio:
`MCP_AUTH_TOKEN` is irrelevant in stdio mode — there is no network listener.
## Publishing an image
The Pi never builds: `deploy/docker-compose.pi.yml` runs
`registry.mc02.dev/schulcloud-mcp` and removes the build section it would
otherwise inherit. Images are published from a development machine:
```bash
npm run publish-image
docker buildx imagetools inspect registry.mc02.dev/schulcloud-mcp:latest # lists linux/amd64 and linux/arm64
```
`scripts/publish-image.sh` refuses a working tree with uncommitted changes, so
a tag always names exactly the code of one commit. It builds for `linux/amd64`
and `linux/arm64` and pushes two tags: `latest`, and the short commit id. It
also labels the image with the full commit and the repository.
Two things the build machine needs:
- **arm64 emulation on an x86 machine.** The script checks for it and prints
the command that registers QEMU —
`docker run --privileged --rm tonistiigi/binfmt --install arm64` — which lasts
until the next reboot.
- **A multi-platform push.** Docker's containerd image store supports it
directly. Without that store, create a builder first with
`docker buildx create --use`.
## Updating
```bash
cd /opt/schulcloud-mcp && git pull
docker compose up -d --build
docker compose exec schulcloud-mcp node -e "1" # sanity
npm run probe # re-verify the API assumptions
npm run publish-image # on a development machine
cd /opt/schulcloud-mcp && git pull # on the Pi: compose files and docs
docker compose pull && docker compose up -d
npm run probe # on a development machine: re-verify the API assumptions
```
A `.env` that pins `SCHULCLOUD_MCP_TAG` needs the new commit id before the pull.
Setting the previous one rolls back.
## Operational notes
- **Restart policy** is `unless-stopped`; the container comes back after a
@@ -179,9 +334,10 @@ npm run probe # re-verify the API assumptions
invalidates them; Claude re-initializes transparently.
- **Logs** are capped at 3 × 10 MB. The Authorization header is never logged.
- **The container is read-only** with `cap_drop: ALL` and `no-new-privileges`,
running as the unprivileged `node` user. The one writable path is the mirror
volume at `/data/mirror`, which holds downloaded file bytes; everything else
stays read-only.
running as the unprivileged `node` user. The writable paths are the mirror
volume at `/data/mirror`, which holds downloaded file bytes, and the state
volume at `/data/state`, which holds a replaced Schulcloud token (mode 0600).
Both belong in no backup that leaves the Pi unencrypted.
- **The mirror grows.** It holds a copy of every course file under
`MIRROR_MAX_BYTES` (64 MiB default). Larger files — videos, mostly — are
indexed as metadata and proxied live on request instead. Budget a few GB.
@@ -196,6 +352,8 @@ npm run probe # re-verify the API assumptions
the session and its auto-logout will revoke it ~2h after login. Copy the
cookie in a private window and close it — see docs/AUTH.md.
- **Downtime longer than two hours lapses the session** and restarting does not
recover it: a long power cut means pasting a fresh `TSC_JWT_COOKIE`.
- **Monthly chore**: refresh `TSC_JWT_COOKIE` before its 30-day hard expiry.
`npm run probe` reports both clocks.
recover it: a long power cut means handing the server a fresh token
(`schulcloud token set`), no restart needed.
- **Monthly chore**: replace the token before its 30-day hard expiry — see
*Replacing the Schulcloud token*. `schulcloud token` and `npm run probe`
report the clocks.

View File

@@ -14,8 +14,22 @@ curl -s http://127.0.0.1:8080/healthz # {"status":"ok","sessions":0,"index":
`docker-compose.override.yml` is merged automatically and is **local-only**: it
publishes the server on `127.0.0.1:8080` and Postgres on `127.0.0.1:55432`, and
switches crawling to on-demand. On the Pi neither port is published — Caddy
reaches the container over the Docker network.
switches crawling to on-demand. Two `.env` settings adjust it: `MCP_HOST_PORT`
moves the published port when 8080 is taken, and `CRAWL_INTERVAL_MS` restores a
crawl timer — worth doing against a real account, because `what_changed` can
only report what happened between crawls.
A fresh Schulcloud token goes in with `schulcloud token set`, or on the page at
`http://127.0.0.1:8080/token` — no restart; see docs/CLI.md. Editing
`TSC_JWT_COOKIE` in `.env` instead needs the container **recreated**, because
`env_file` is read when the container is created, not on restart:
```bash
docker compose up -d --force-recreate schulcloud-mcp
```
On the Pi neither port is published — Caddy reaches the container over the
Docker network.
Loopback binding is deliberate. The bearer token is the only thing in front of
your account's data, so it should not be listening on your LAN while you test.
@@ -93,9 +107,9 @@ node dist/bin/cli.js sync
## Run the test suites
```bash
npm test # 58 offline tests
npm test # 239 offline tests
npm run smoke # end-to-end against the live instance, live-only mode
DATABASE_URL=… npm run smoke # end-to-end with the index (34 checks)
DATABASE_URL=… npm run smoke # end-to-end with the index (93 checks with a WebUntis key)
```
Store tests need a database and skip without one:

308
docs/NOTES.md Normal file
View File

@@ -0,0 +1,308 @@
# Your own lesson notes
Schulcloud holds the material and WebUntis holds the schedule. Neither holds
what was actually said in the room — which teacher stressed what, the example
that finally made it click, the aside that turns up in the test. That is in
whatever you write down during the lesson, and until now it lived somewhere no
agent could read.
This is the third source: a directory of Markdown files the server reads,
indexes and searches alongside everything else, and a small web app to write
them in.
## One note per school day
```
NOTES_DIR/
2026/
2026-09-18.md ← Freitag, one heading per lesson
2026-09-21.md
Deutsch/
2026-09-15 Erörterung.md ← a single-subject note, e.g. from the import
```
A day note looks like this, and the shape is load-bearing:
```markdown
---
title: Freitag, 18.09.2026
date: 2026-09-18
source: notes-page
---
## 1. Deutsch — 08:0008:45 · MEI · R 204
Dreischritt: These, Argument mit Beleg, Fazit.
### Aufbau
- Gegenargument nicht vergessen — kam letztes Jahr in der Arbeit dran
## 2. LF07 — 08:5009:35 · Sb · R 108
| Präfix | Nutzbare Adressen |
|---|---|
| /24 | 254 |
```
**Each `##` is indexed as its own lesson.** A search for *Gegenargument* answers
"my own note, Deutsch, 18.09.2026", not "my own note, Friday"; `list_notes
subject=Deutsch` finds this day even though the note's frontmatter names no
subject; and `what_changed` reports the lesson that changed rather than the
whole day. Prose, lists, tables and `###` subheadings all live inside their
lesson.
Nothing forces the shape. A note with no `##` headings — an imported Apple Note,
a page of revision — is indexed whole, as one piece of prose.
## The app
`/app` is a small web app: a login, the day's notes, and a settings page. It is
served only when `WEB_PASSWORD` is set.
```
https://mcp.example.org/app/
```
On a phone it is worth adding to the home screen — it has a manifest and opens
standalone, which is the difference between "a page I have to find" and "the
thing I open in a free period".
**Notizen** is one screen: the day, ` ` to move between days, and the editor.
- Opening a day with no note yet **fills in that day's lessons from WebUntis**
numbered, with times, teacher and room, cancellations left out and
substitutions marked. That is the whole reason the app is day-shaped: the one
thing WebUntis knows and you should not have to retype.
- **Stunden ergänzen** appears when the timetable has a lesson your note does
not — a day you started writing before it ended, or a timetable that changed
after you started. It appends what is missing and never touches what you wrote.
- It **saves as you type** (a couple of seconds after you stop), when the app
goes to the background, and when the phone locks.
- Every keystroke also goes to the browser's local storage. If the connection
drops mid-lesson you keep writing, and the next time the app loads that day it
offers the version this device has. **A note taken in a lesson cannot be
retaken**, which is the reasoning behind all of this.
- If the note changed elsewhere since you opened it — the laptop, a sync tool,
`add_note` — the save is refused and you are asked which version wins. It
never silently overwrites.
### Writing, without typing Markdown
The editor shows the note **formatted** — headings as headings, bold as bold,
tables as tables — and the toolbar above it writes the Markdown. Nobody types
`##` or `**` during a lesson.
| Button | What it writes |
| --- | --- |
| `H2` | a lesson heading — the one that makes the lesson separately searchable |
| `H3` | a subheading inside a lesson |
| `F` `K` `S` | **fett**, _kursiv_, ~~durchgestrichen~~ (`Strg`/`Cmd` + B, I) |
| `<>` | inline code (`Strg`/`Cmd` + E) |
| `• —` `1. —` | bullet and numbered lists; nest them with Tab |
| `☐` | a box to tick off |
| `❝` | a quote — the teacher's exact wording |
| `🔗` `▦` | a link (`Strg`/`Cmd` + K) and a table |
| `MD` | the Markdown itself |
`Enter` starts a new paragraph, `Shift+Enter` a new line in the same one. A
paste from a web page or a PDF keeps its structure and loses its fonts, colours
and anything else that is not in the list above — pasted HTML is converted to
Markdown before it reaches the page, which is what keeps a copied page from
bringing its script along.
**The file is still Markdown.** `MD` shows it and lets you edit it directly,
which is the way to write something the toolbar has no button for. There is no
underline, because Markdown cannot store one — `F` or `K` instead.
Opening a note may tidy it once: `*so*` becomes `_so_`, a table typed unevenly
lines up. Nothing is rewritten until you actually change something, and a note
whose formatting the view cannot hold unchanged **opens as Markdown** and says
so rather than being quietly reduced. `test/app-markdown.test.ts` is what holds
that promise up: every construct in this document goes in and comes back out
unchanged.
**Einstellungen** holds the Schulcloud token: how long it has left, and the box
to paste a fresh `jwt` cookie into when it expires (the same thing `schulcloud
token set` and the older `/token` page do). It also shows the index's state and
the notes directory, and has the logout button.
### The login
`WEB_PASSWORD` is the app's password — at least 12 characters, and a passphrase
of three or four words is the right shape. It is hashed with scrypt at startup;
the plain value is never stored, compared or logged.
Logging in sets an `HttpOnly`, `SameSite=Strict` session cookie that lasts 30
days, so a lesson never starts with a login screen. The cookie opens `/api`
it *is* the user — but not `/mcp`, which no browser needs. Changing
`WEB_PASSWORD` invalidates every session, because the signing key is derived
from it. Failed logins are rate-limited per address; a password is guessable in
a way a 32-character token is not, and this endpoint is on the internet.
If `WEB_PASSWORD` is unset the app is not served at all — the same rule the
`untis_*` and note tools follow. A login screen that no password can open is
worse than no page, because it looks like a way in.
## The other four ways to write a note
The app is not privileged; it writes the same files as everything else.
```bash
# the command line, text piped in
pbpaste | schulcloud note add --title "Subnetting" --subject LF07
# an editor, or anything else that writes files
$EDITOR "$NOTES_DIR/2026/2026-09-18.md"
# Claude, during or after the lesson
# "halt fest: Gegenargument nicht vergessen, kam letztes Jahr dran"
# → add_note, subject Deutsch, today's date
# a folder you already sync — see below
```
`add_note` and `schulcloud note add` take `append`, which adds to the note
already written **for that subject and that day** rather than starting a second
one — so "note this down too" mid-lesson lands in the note that is already open,
whatever its title.
## What may write, and where
**The notes directory is the only thing this server writes to.** Not Schulcloud,
not WebUntis — those stay read-only, strictly, and that has not changed.
Every path component goes through `safeComponent` and the result through
`resolveWithin`, the same two functions that stop a hostile Schulcloud filename
escaping the file mirror, so a note titled `../../.ssh/authorized_keys` becomes
a filename. `NOTES_READONLY=1` refuses writes entirely — right when the notes
are synced in from somewhere else and should have exactly one writer. The app
then still reads them, and says so rather than failing on save.
## Migrating out of Apple Notes
Notes.app has no export. Its database is a Core Data store whose bodies are
compressed protobuf and whose iCloud copy is encrypted, so scripting the app is
not the clumsy route to your notes — it is the only one.
**On the Mac**, from a checkout of this repo:
```bash
osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson
osascript -l JavaScript scripts/export-apple-notes.js --folder Schule > schule.ndjson
```
The first run raises a macOS permission dialog ("Terminal wants access to
Notes"); without it every note comes back empty.
**`--folder Schule` exports one folder and everything under it.** Folder names
are matched whole, not as a fragment, and the path is recorded relative to the
one you named — so `Schule/Deutsch` arrives as the subject `Deutsch`, and a note
sitting loose in `Schule` arrives with no subject rather than one called
"Schule". Reading a note's body is the expensive part, so filtering here rather
than afterwards is also what makes a large library finish.
The records go to standard output and the progress line to standard error, so
`> notes.ndjson` gets exactly the notes. Check it took: `wc -l notes.ndjson`
should be the number the script reported.
Then look at what it would do, and do it:
```bash
schulcloud note import notes.ndjson --dry-run
schulcloud note import notes.ndjson # into the server
schulcloud note import notes.ndjson --out ~/Notizen # or to a local folder first
```
```
would import: 2026-09-15 · Deutsch · Erörterung
Would import 84 of 91 note(s), skipped 5 with no text, 2 unreadable in Notes.
```
What it does with each note:
- **The Notes folder becomes the subject** — its last segment, so `Schule/Deutsch`
is `Deutsch`. Notes' own default folders (`Notizen`, `Recently Deleted`) are
ignored rather than becoming a subject. `--subject LF07` overrides all of it.
- **The creation date becomes the note's date**, because that is the day of the
lesson. The modification date is whenever you last tidied it up, which is not
a school day at all.
- **The HTML becomes Markdown** — headings, lists, checklists, bold, italics and
links survive; anything else keeps its words and loses its tag.
- **Attachments do not come across.** A note that was a photo of the board
imports as a line saying an attachment was there. Better than importing empty:
you can see which notes still need the picture.
- **Locked notes cannot be read at all** and are listed by name at the end.
Unlock them in Notes and export again.
Imported notes arrive as single-subject notes, not day notes, which is the
shape they were written in. Both kinds coexist.
Re-running the import creates second copies rather than overwriting, since the
importer cannot tell an edited note from a new one with the same title. Import
once, then keep writing in the new place.
## Reading them
| Tool | Answers |
|---|---|
| `list_notes` | "what did I write down in Deutsch before the test" — by subject or date, lesson headings included |
| `get_note` | one note in full, by the path everything else prints |
| `search` | notes by their contents, per lesson, next to board text and the inside of PDFs |
| `what_changed` | lessons and notes that appeared or were edited since a date |
The `pruefungsvorbereitung`, `zusammenfassung` and `tagesvorbereitung` prompts
consult them on their own, and are told to say when a note disagrees with the
uploaded material rather than quietly preferring one.
## Deploying it
Adding this to a server that is already running — the password, where the notes
live, backups and rollback — is [DEPLOY-NOTES.md](DEPLOY-NOTES.md).
## Keeping them somewhere you already sync
The notes are files, so any sync tool will do and the server does not need to
know which. Bind-mount the folder instead of using the volume:
```yaml
# docker-compose.yml, under schulcloud-mcp:
volumes:
- /home/pi/Notizen:/data/notes
```
Then point Syncthing, Nextcloud, an Obsidian vault or `git` at
`/home/pi/Notizen`. New files are picked up by the next crawl; nothing has to be
told about them, and the app edits the same files.
Frontmatter is a small YAML subset — scalars and lists, in either the inline
`[a, b]` or the indented `- item` form editors write — so an Obsidian vault
round-trips. Unknown keys are kept and ignored.
What the server reads out of a note:
| Field | Falls back to |
|---|---|
| `title` | the first `# heading`, else the filename without its date |
| `date` | a `YYYY-MM-DD` the **filename** starts with, else nothing |
| `subject` | the first folder name — except a year, which is a filing scheme |
| `tags` | none |
| `courseId` | none — set it to tie a note to a Schulcloud course in `search` |
`date` also accepts `15.09.2026`, and `fach:` works as a German spelling of
`subject:`.
**The file's modification time is never used as the date.** An import writes
every note today; dating a year of lessons "today" would make the whole store
useless for revision, which is most of what it is for.
## What the crawl does with them
A **full** crawl reads the directory and indexes every note — one entry per
lesson for a day note, one for the whole note otherwise, under the kind `note`.
A **per-course** refresh leaves them alone, and the store carries the previous
generation's rows forward, so a per-course crawl never looks like the notes were
deleted.
Notes are diffed by content, not by modification time, so a sync tool that
rewrites a file byte-for-byte does not show up in `what_changed` as an edit.

487
docs/PI.md Normal file
View File

@@ -0,0 +1,487 @@
# Setting up on the Raspberry Pi
A step-by-step guide from a Pi with Docker to a working claude.ai connector.
[DEPLOYMENT.md](DEPLOYMENT.md) explains *why* each piece is there; this page is
the order to do it in.
The Pi never builds the server. It pulls the image published to
`registry.mc02.dev`, built for arm64 and amd64 by `npm run publish-image` on a
development machine ([DEPLOYMENT.md](DEPLOYMENT.md#publishing-an-image)).
```
claude.ai ─┐ ┌──────────── Pi (home, always on) ─────────────┐
Claude Code├─HTTPS─▶ VPS ─TCP─▶ │ Caddy ─▶ schulcloud-mcp ─▶ schulcloud-thueringen.de
CLI ───────┘ (public IP, │ (TLS) └─ postgres (private network) │
your domain) └────────────────────────────────────────────────┘
```
## What you need
- **The Pi**, always on. More than two hours offline ends the Schulcloud
session, and only a new token from a browser revives it. A Pi 5 on 64-bit Raspberry Pi
OS is plenty; put Docker's data on an SSD rather than the SD card if you can,
since Postgres and the file mirror (about 1.3 GB on the current account) write
to it.
- **Docker Engine with the Compose plugin**, Compose 2.24 or later, on the Pi.
- **A login for `registry.mc02.dev`**, where the image is published.
- **Caddy running in a container on the Pi**, serving ports 80 and 443. This
server joins its Docker network; it opens no port of its own.
- **The VPS**, with a public IPv4 address, forwarding TCP ports 80 and 443 to the
Pi (step 7).
- **A hostname** in your domain — `mcp.example.org` below — whose `A` record
points at the VPS.
- **Your Schulcloud login**, for the first token, and a machine with the
`schulcloud` CLI (`npm link` in a checkout; see [CLI.md](CLI.md)).
## 1. Prepare the Pi
```bash
sudo apt update && sudo apt full-upgrade -y
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER" # then log out and back in
docker compose version # v2.24 or later
timedatectl # "System clock synchronized: yes"
docker login registry.mc02.dev
```
The clock matters: token expiry and TLS certificates are both judged by it. The
login is needed to pull, not only to push; Docker keeps it in
`~/.docker/config.json`.
## 2. Get the compose files
```bash
sudo mkdir -p /opt/schulcloud-mcp && sudo chown "$USER": /opt/schulcloud-mcp
git clone https://git.mc02.dev/fabi/Schulcloud-MCP.git /opt/schulcloud-mcp
cd /opt/schulcloud-mcp
```
Every command below runs in `/opt/schulcloud-mcp`. The checkout provides the
compose files, `.env.example` and the Caddy snippet; its source code is not built
here.
## 3. Configure
Find the Docker network your Caddy container is on:
```bash
docker ps --format '{{.Names}}' | grep -i caddy
docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' <caddy-container>
```
Create `.env`, readable by you alone, and add the Pi's settings with freshly
generated secrets — replace `<network>` with the name you just found:
```bash
cp .env.example .env && chmod 600 .env
sed -i '/^MCP_AUTH_TOKEN=$/d; /^DATABASE_URL=/d' .env
cat >> .env <<EOF
# --- the Pi ---
COMPOSE_FILE=docker-compose.yml:deploy/docker-compose.pi.yml
CADDY_NETWORK=<network>
POSTGRES_PASSWORD=$(openssl rand -hex 24)
MCP_AUTH_TOKEN=$(openssl rand -hex 32)
MCP_CONNECTOR_TOKEN=$(openssl rand -hex 32)
INDEX_PERSONAL_FILES=true
# SCHULCLOUD_MCP_TAG=latest
EOF
```
To write your own lesson notes — and to replace the Schulcloud token from a
phone rather than a terminal — set a password for the app at `/app`. Unlike the
tokens above it is typed by a person, so it is a passphrase: **at least 12
characters, three or four words**. See [NOTES.md](NOTES.md).
```bash
cat >> .env <<'EOF'
WEB_PASSWORD=change-this-to-three-or-four-words
EOF
```
If your school publishes its timetable in **WebUntis**, add those four values
too — the timetable, its cancellations and substitutions are not in Schulcloud
at all. They are in WebUntis under Profil → Freigaben → Untis Mobile → QR-Code:
```bash
cat >> .env <<'EOF'
UNTIS_SERVER=yourschool.webuntis.com
UNTIS_SCHOOL=yourschool
UNTIS_USER=your.username
UNTIS_SECRET=THEKEYFROMTHEQRDIALOG
EOF
```
What those lines do:
| Setting | |
|---|---|
| `COMPOSE_FILE` | Makes every `docker compose` command here use [`deploy/docker-compose.pi.yml`](../deploy/docker-compose.pi.yml), which runs the registry image — removing the build section, so nothing can be built here — joins Caddy's network and wires up the database. It also keeps `docker-compose.override.yml` out — that one is for local development and would publish ports and switch the crawl timer off. |
| `CADDY_NETWORK` | The network Caddy reaches this server on, by the name `schulcloud-mcp`. |
| `POSTGRES_PASSWORD` | The bundled Postgres, which sits on a private network with this server only. Hex, so it needs no escaping inside the connection URL. |
| `MCP_AUTH_TOKEN` | What Claude Code, the CLI and the `/token` page present. |
| `MCP_CONNECTOR_TOKEN` | What claude.ai sends as a request header. It opens `/mcp` only, never `/api`, because claude.ai stores it. |
| `INDEX_PERSONAL_FILES` | Also indexes your own files and handed-in work, including teachers' feedback. Optional. |
| `UNTIS_*` | WebUntis, where the school keeps the timetable. All four or none; the key needs no password and does not expire. See [AUTH.md](AUTH.md). Optional. |
| `WEB_PASSWORD` | The app at `/app`: the notes editor and a settings page for the Schulcloud token. Unset means no app is served at all. Hashed at startup and never logged; changing it logs out every session. Optional. |
| `SCHULCLOUD_MCP_TAG` | Which published image to run. Unset means `latest`; a commit id such as `bac9130` pins it, so updates happen only when you change it. Optional. |
**Copy `MCP_AUTH_TOKEN`, `MCP_CONNECTOR_TOKEN` and `WEB_PASSWORD` into your
password manager now** — you need the first two again in step 9, and none of
them is ever printed by the server:
```bash
grep -E '^(MCP_AUTH_TOKEN|MCP_CONNECTOR_TOKEN|WEB_PASSWORD)=' .env
```
## 4. The first Schulcloud token
1. Open a **private window** and log in to Schulcloud.
2. DevTools (F12) → *Application* (Firefox: *Storage*) → *Cookies* → the cookie
named `jwt` → copy its value.
3. Put it into `.env` as `TSC_JWT_COOKIE=<value>`.
4. **Close the private window.** Left open, it logs the token out about two hours
after login ([AUTH.md](AUTH.md) has the whole story).
This is the only time the token goes into `.env`. Later ones go in without a
restart (step 11).
## 5. Start the server
```bash
docker compose pull # the published image, and Postgres
docker compose up -d
docker compose ps
docker compose logs -f schulcloud-mcp
```
Nothing is built on the Pi: `pull` fetches the arm64 variant of the image, and
with the Pi file in place no `docker compose` command can fall back to a build.
Expect, within a few seconds:
```
[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://schulcloud-thueringen.de, auth enabled (plus connector token), token from environment, 29 day(s) left, keepalive every 30min, index every 6h
[schulcloud-mcp] keepalive: session extended, 7200s (120 min) of budget left
```
- `auth DISABLED` means `MCP_AUTH_TOKEN` is empty. Stop and fix it.
- `keepalive: token rejected (401)` means the token is already dead: get a fresh
one (step 4) and hand it over with `schulcloud token set` once step 9 is done.
The index is empty, so the first full crawl starts on its own. It downloads every
course file once — about 15 minutes and 1.3 GB on the current account — and the
server answers normally meanwhile.
`docker compose ps` must show **no published ports** for either container.
## 6. Add the site to Caddy
Append [`deploy/Caddyfile.snippet`](../deploy/Caddyfile.snippet) to the Pi's
Caddyfile, with your hostname in place of `mcp.example.org`, then validate and
reload:
```bash
docker exec <caddy-container> caddy validate --config /etc/caddy/Caddyfile
docker exec <caddy-container> caddy reload --config /etc/caddy/Caddyfile
```
Keep the snippet's three easily-missed settings:
- `flush_interval -1`, or claude.ai's connection hangs without an error.
- The long timeouts, or a slow `search` is cut off.
- The `format filter` in `log`, which keeps a secret path out of the access log if
you ever use one (step 9).
Caddy gets its certificate once DNS and the forwarding work (step 7). Watch for
it with `docker logs -f <caddy-container> | grep -i certificate`.
## 7. DNS and the VPS
**DNS:** an `A` record for `mcp.example.org` pointing at the VPS's public IPv4.
Publish no `AAAA` record unless the VPS forwards IPv6 as well.
**The forwarding must pass TCP through, untouched.** TLS has to end at Caddy on
the Pi. A VPS that terminates TLS itself, or proxies HTTP, sees every request —
the claude.ai token in its header included — and may log it. Both ports are
needed: 80 for the certificate challenge, 443 for everything else.
If the VPS already forwards to the Pi, check how. On the VPS:
```bash
sudo ss -ltnp '( sport = :443 )'
```
No listening process is the good answer: the kernel forwards the packets, as in
the example below. An nginx `stream` block or HAProxy in `mode tcp` is also fine.
An nginx `http` server, a Caddy, or HAProxy in `mode http` on the VPS is not.
### Example: WireGuard and nftables
Skip this if your forwarding already passes TCP through. Otherwise, a minimal
tunnel with the VPS as `10.8.0.1` and the Pi as `10.8.0.2`. Create keys on each
machine with `umask 077; wg genkey | tee private.key | wg pubkey > public.key`.
On the VPS, `/etc/wireguard/wg0.conf`:
```ini
[Interface]
Address = 10.8.0.1/24
ListenPort = 51820
PrivateKey = <vps private key>
[Peer]
PublicKey = <pi public key>
AllowedIPs = 10.8.0.2/32
```
On the Pi, `/etc/wireguard/wg0.conf`:
```ini
[Interface]
Address = 10.8.0.2/24
PrivateKey = <pi private key>
[Peer]
PublicKey = <vps public key>
Endpoint = <vps public ip>:51820
AllowedIPs = 10.8.0.1/32
PersistentKeepalive = 25
```
Bring the tunnel up on both (`sudo apt install wireguard` first), and keep it up
across reboots:
```bash
sudo systemctl enable --now wg-quick@wg0
ping -c 3 10.8.0.1 # from the Pi
```
On the VPS, turn on forwarding and send ports 80 and 443 into the tunnel.
Replace `eth0` with the VPS's public interface (`ip route get 1.1.1.1` names it):
```bash
echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-forward.conf
sudo sysctl --system
```
```nft
# /etc/nftables.conf on the VPS (merge into what is there)
table ip schulcloud_forward {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname "eth0" tcp dport { 80, 443 } dnat to 10.8.0.2
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "wg0" ip daddr 10.8.0.2 tcp dport { 80, 443 } masquerade
}
}
```
```bash
sudo nft -c -f /etc/nftables.conf && sudo systemctl enable --now nftables
```
If the VPS's `forward` chain has `policy drop`, also accept `ct state
established,related` and new TCP 80/443 from `eth0` to `wg0`. Its firewall
needs 80/tcp, 443/tcp and 51820/udp open, plus SSH. With the masquerade, Caddy's
logs show the VPS's tunnel address as the client; nothing here relies on client
addresses.
Check from the VPS that the Pi's Caddy answers through the tunnel:
```bash
curl -sI http://10.8.0.2/ -H 'Host: mcp.example.org' | head -1 # a redirect to https
```
## 8. Check it from outside
From any machine that is not the Pi:
```bash
curl -s https://mcp.example.org/healthz
# {"status":"ok","sessions":0,"index":"on"}
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.org/mcp \
-H 'content-type: application/json' -d '{}'
# 401 ← the bearer check
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.org/$(openssl rand -hex 32)/mcp \
-H 'content-type: application/json' -d '{}'
# 404 ← a wrong path secret, answered like any unknown path
```
Then confirm that TLS really ends on the Pi: the certificate the world sees is
the one Caddy there obtained.
```bash
echo | openssl s_client -connect mcp.example.org:443 -servername mcp.example.org 2>/dev/null \
| openssl x509 -noout -issuer -enddate # from outside
docker logs <caddy-container> 2>&1 | grep -i 'certificate obtained' | tail -1 # on the Pi
```
## 9. Connect Claude and the CLI
**CLI**, on your laptop:
```bash
schulcloud login --server https://mcp.example.org --token <MCP_AUTH_TOKEN>
schulcloud token # expires … (29 day(s) left); session alive, 120 min budget; from TSC_JWT_COOKIE
schulcloud status # after the first crawl: generation 1 — crawled … min ago
```
**Claude Code**, replacing the registration that points at the laptop:
```bash
claude mcp remove --scope user schulcloud
claude mcp add --transport http --scope user schulcloud https://mcp.example.org/mcp \
--header "Authorization: Bearer <MCP_AUTH_TOKEN>"
claude mcp list # schulcloud: https://mcp.example.org/mcp (HTTP) - ✔ Connected
```
**claude.ai:**
1. *Customize → Connectors → Add custom connector*. Name: `Schulcloud`. URL:
`https://mcp.example.org/mcp`.
2. The next step shows **No sign-in** as detected — keep it — and a **Request
headers** section. Add one: name `authorization`, value
`Bearer <MCP_CONNECTOR_TOKEN>`, with the space. Add the connector.
3. In a chat: **+ → Connectors** → switch *Schulcloud* on, and ask *"Welche Kurse
habe ich?"*
claude.ai stores the header and never shows it again; to change it, remove the
connector and add it again.
*Without request headers* — say, for a client that cannot send them — use a
secret path instead:
```bash
echo "MCP_PATH_SECRET=$(openssl rand -hex 32)" >> .env
docker compose up -d --force-recreate schulcloud-mcp
```
The URL is then `https://mcp.example.org/<MCP_PATH_SECRET>/mcp`, with no header.
It is the credential itself, so keep it out of screenshots and notes;
[DEPLOYMENT.md](DEPLOYMENT.md#without-request-headers--a-secret-path) says what
that trades away.
**Retire the laptop's container** once the Pi answers — in the laptop checkout,
`docker compose down` keeps its index and mirror volumes. Its session is separate
and simply lapses.
## 10. Verify the first crawl
```bash
docker compose logs schulcloud-mcp | grep 'scheduled crawl'
# [schulcloud-mcp] scheduled crawl: generation 1, 1030 files, … newly extracted, …s
```
A second crawl runs every six hours and downloads only what is new.
## 11. The monthly token
The token lasts 30 days. From a week before, the log, `whoami` and
`schulcloud token` warn. Replacing it takes a minute and no restart:
1. Private window → log in → copy the `jwt` cookie (as in step 4).
2. `schulcloud token set` and paste it — or open `https://mcp.example.org/token`
and paste it there with `MCP_AUTH_TOKEN`.
3. Close the private window.
The server checks the token with Schulcloud before using it, restarts the
keepalive, and saves it in the `state` volume, where it outlives restarts and
takes precedence over the older one in `.env`.
The same steps revive a session that lapsed — after a power cut of more than two
hours, say.
## Updating
A new version is published first, from a development machine:
`npm run publish-image` ([DEPLOYMENT.md](DEPLOYMENT.md#publishing-an-image)).
Then, on the Pi:
```bash
cd /opt/schulcloud-mcp
git pull # compose files and docs
docker compose pull # the newly published image
docker compose up -d
docker compose logs --tail 20 schulcloud-mcp
```
`COMPOSE_FILE` in `.env` keeps applying the Pi settings. Recreating the container
keeps the database, the mirror and a replaced token, all of which live in volumes.
With `SCHULCLOUD_MCP_TAG` pinned, set it to the new commit id before pulling.
Going back works the same way: set the previous commit id, then `pull` and
`up -d`.
## Backups
Everything this server stores is a copy of something upstream — with one
exception. **Your own notes are not a copy of anything**, and nothing can
rebuild them.
| What | Needed? |
|---|---|
| `.env` | **Yes** — it holds every secret. Only ever back it up encrypted. |
| **`schulcloud-mcp_notes`** | **Yes. The only irreplaceable thing here** — a note taken in a lesson cannot be retaken. Small: a school year is a few megabytes. |
| Postgres | Optional: a crawl rebuilds it. `docker compose exec postgres pg_dump -U schulcloud schulcloud \| gzip > index.sql.gz` |
| `schulcloud-mcp_mirror` | No — re-downloaded by the next crawl. |
| `schulcloud-mcp_state` | No — a replaced token, expiring within 30 days anyway. |
```bash
docker run --rm -v schulcloud-mcp_notes:/notes:ro -v "$PWD":/out alpine \
tar czf /out/notizen-$(date +%F).tar.gz -C /notes .
```
If you bind-mounted a directory you already sync ([NOTES.md](NOTES.md)), back
that up instead — and check the sync tool keeps versioned copies rather than
assuming it does.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| `keepalive: token rejected (401)` | The session ended: the Pi was off for more than two hours, a Schulportal tab was left open, or 30 days passed | Step 11 |
| 401 about two hours after pasting a token | A Schulportal tab still open on that login | Close it, then step 11 |
| claude.ai cannot add the connector | DNS, forwarding or certificate not in place; a missing or mistyped request header (401); or, with a secret path, a wrong secret (404) | Step 8's checks, in order, then the header |
| The connector token works on `/mcp` but not with the CLI | By design: it is refused on `/api` | The CLI uses `MCP_AUTH_TOKEN` |
| claude.ai connects, then tools hang | `flush_interval -1` missing from the Caddy site | Step 6 |
| `/mcp` answers 401 with the right token | Whitespace copied along with the token | Re-copy it |
| Caddy never obtains a certificate | Port 80 not forwarded, or DNS not yet pointing at the VPS | Step 7 |
| `network … declared as external, but could not be found` | Wrong `CADDY_NETWORK` | Step 3 |
| `required variable … is missing a value` | A line missing from `.env` | Step 3 |
| `unauthorized` or `pull access denied` from `docker compose pull` | Not logged in to the registry on the Pi | `docker login registry.mc02.dev` |
| `no matching manifest for linux/arm64` | The image was published for amd64 only | Publish again with `npm run publish-image`, which builds both |
| Compose rejects `!reset` in `deploy/docker-compose.pi.yml` | Compose older than 2.24 | Update Docker (step 1) |
| `WebUntis rejected the server's key` from a untis_* tool | The key was regenerated in WebUntis, or `UNTIS_USER` does not match it | Copy both again from Profil → Freigaben → Untis Mobile, then `docker compose up -d --force-recreate schulcloud-mcp` |
| `the server's clock is too far off` from a untis_* tool | The Pi's clock has drifted; the Untis code is time-based | `timedatectl status`, then fix NTP |
| `EACCES` for `/data/state`, `/data/mirror` or `/data/notes` in the logs | A volume created by an old image, owned by root | `docker compose down`, `docker volume rm schulcloud-mcp_state` (or `_mirror`) and `docker compose up -d`. **Never delete `_notes`** — back it up, then fix the ownership: `docker run --rm -v schulcloud-mcp_notes:/n alpine chown -R 1000:1000 /n` |
| `/app/` answers 404 | `WEB_PASSWORD` not set, or the container not recreated since it was | `grep ^WEB_PASSWORD= .env`, then `docker compose up -d --force-recreate schulcloud-mcp` |
| The app logs you out constantly | The session cookie is marked `Secure` but the connection is not HTTPS, or Caddy's `X-Forwarded-Proto` is being stripped | Reach it over HTTPS; leave that header alone |
| Notes exist but `search` cannot find them | Only a **full** crawl reads them | `schulcloud refresh --force` |
## Security checklist
- `.env` is mode 600 and in no unencrypted backup.
- `docker compose ps` shows no published ports for `schulcloud-mcp` or `schulcloud-mcp-db`.
- The registry login in `~/.docker/config.json` is only base64-encoded. Keep the
Pi user's home private, and use an account that may only pull if your
registry can issue one.
- The VPS forwards raw TCP and opens only 80, 443, 51820/udp and SSH.
- With a secret path set, it stays out of Caddy's access log — this prints a
count, never the secret:
```bash
docker exec <caddy-container> grep -c "$(grep '^MCP_PATH_SECRET=' .env | cut -d= -f2)" /var/log/caddy/schulcloud-mcp.log
# 0
```
- If claude.ai's token may have leaked: set a new `MCP_CONNECTOR_TOKEN`, run
`docker compose up -d --force-recreate schulcloud-mcp`, and re-add the
connector with the new header. A leaked secret path is replaced the same way.
`MCP_AUTH_TOKEN` stays valid either way.
- `WEB_PASSWORD` is the one credential here that can be guessed, so its length
is what protects the notes and the Schulcloud token. Changing it and
recreating the container logs out every session — that is the revocation
mechanism, and the thing to do if a phone is lost.

View File

@@ -94,12 +94,48 @@ 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
currently just "here is a file you cannot read".
- **MCP resources and prompts**expose courses/boards as attachable resources;
canned prompts such as "summarise this week's homework".
- **MCP resources and prompts — partly built.** Courses and rooms are resources,
and there are two German prompts (`zusammenfassung`, `pruefungsvorbereitung`).
Still open: files as resources — about a thousand of them do not fit a picker
that lists every entry, so they need a browsing UI (an MCP App) rather than a
longer list — and claude.ai, which cannot reach a server on localhost and does
not send a static bearer token.
- **Calendar** — a separate `schulcloud-calendar` service, not part of the v3 API
mapped in `API.md`.

View File

@@ -89,6 +89,7 @@ Every one of these is marked in the env files at the line it affects.
```
docker-compose.yml the stack; profiles: (default) | tools | av | preview
(minio-loopback: see "Simulating a teacher")
env/ one file per service, all values local-only
proxy/nginx.conf GENERATED — the single origin, see scripts/gen-proxy-conf.py
scripts/seed.sh loads the demo school via the management app
@@ -183,16 +184,65 @@ node scripts/simulate-teacher.mjs create # course, room, topic, task, board,
# Etherpad pad, folder, files
node scripts/simulate-teacher.mjs update # rename and rewrite all of it
node scripts/simulate-teacher.mjs delete # remove it again
node scripts/simulate-teacher.mjs files # only the file-manager part, onto an existing fixture
```
It also fills the **file manager** ("Dateien") — the legacy file system behind
Persönliche, Kurs-, Team- and Geteilte Dateien, a separate store from the board
files above — with a course folder tree ("Arbeitsblätter/Woche 1"), a team
folder, a folder in the demo student's own files, and a teacher's file shared
read-only with the student. The student is added to the adopted team so the
team files are visible from the account under test. Every file carries a unique
search term, so search over the index can be checked per area.
Legacy file uploads only work because of two changes to the stack:
- **`minio-loopback`.** The legacy service signs upload and download URLs for
its storage provider's single endpoint, and uses that endpoint for its own S3
calls. `minio:9000` works inside the compose network and nowhere else, so the
browser and the MCP server on the host were handed URLs they could not open.
`seed.sh` now registers `http://localhost:9900`, and this socat sidecar,
sharing the api container's network namespace, forwards that address to
MinIO — the same url then works from the api, the browser and the host.
After recreating `api`, recreate `minio-loopback` too: it lives in api's
network namespace.
- **The school bucket is created up front.** The legacy service makes
`bucket-<schoolId>` on first upload and then calls `PutBucketCors`, which
MinIO does not implement, so the first upload failed with *"A header you
provided implies functionality that is not implemented"*. `minio-init.sh`
creates the demo school's bucket, and an existing bucket skips both calls.
Ids are kept in `.simulate-teacher.json` between phases, so the MCP server can
be pointed at the instance in between:
```bash
eval "$(./scripts/mcp-env.sh)" # as the demo student
cd .. && npm run smoke # 39 checks against the local instance
cd .. && npm run smoke # 82 checks against the local instance
```
`mcp-env.sh` points the index at its own database, `schulcloud_local`, and the
mirror at `tmp/mirror-local` — not just the instance at this one, and it switches
WebUntis off, since this instance has no timetable and the key in the root `.env`
is the real school's. The root
`.env` normally targets the live account, and process env beats `--env-file`, so
without that a local smoke run would crawl these fixtures into the live index,
where a per-course refresh then carries them forward indefinitely. Create the
database once:
```bash
docker exec schulcloud-mcp-db psql -U schulcloud -d postgres \
-c "CREATE DATABASE schulcloud_local OWNER schulcloud"
```
Without it the server does not fail; it runs live-only, and smoke reports the
smaller, index-free check count.
It also builds a **room** ("Raum"): rooms are a separate space from courses,
and the naming misleads — the sidebar's *Kurse* entry points at
`/rooms/courses-overview` while *Räume* points at `/rooms`. The fixture adds the
demo student to one room and leaves a second room without them, so "only the
rooms I belong to" is testable rather than assumed.
Two things it does **not** do, because the API does not allow them:
- **Teams cannot be created.** The legacy service registers
@@ -246,6 +296,17 @@ plain HTTP locally.
**A file uploads but will not download.** See the `av` note above.
**Previews never appear, and `/api/v3/file/preview/...` answers 404
PREVIEW_NOT_POSSIBLE.** Two causes, both local. First, with no virus scanner
(see the `av` note) every upload stays `securityCheck.status=pending`, and a
record that has not been scanned reports `previewStatus: awaiting_scan_status`
— previews are gated on the scan. Second, the `file-preview` image ships an
ImageMagick policy written for an older ImageMagick than the 7.1.2 it actually
contains, so every coder it needs is denied and each attempt fails with
*"attempt to perform an operation not authorized by the security policy"*
which the API surfaces as a 404. `file-preview/policy.xml` is mounted over the
image's own to fix the second; the first is inherent to running without `av`.
**H5P element stays empty.** `docker compose --profile tools run --rm
h5p-libraries` and watch it finish; the editor has nothing to offer until the
content types are in the bucket.

View File

@@ -154,12 +154,34 @@ services:
minio: {condition: service_healthy}
restart: unless-stopped
minio-loopback:
# The legacy file service (the file manager: Persönliche/Kurs-/Team-Dateien)
# signs upload and download URLs for its storage provider's one endpoint, and
# the same endpoint serves its own S3 calls. "minio:9000" works inside the
# compose network and nowhere else, so a browser or the MCP server on this
# machine was handed URLs it could not open: uploads through the UI failed
# and downloads could not be tested. seed.sh registers the provider as
# http://localhost:9900 instead, and this forwards that address to MinIO from
# inside the api container's own network namespace — so the one URL now
# works from the api, from the browser and from the host alike.
image: alpine/socat:1.8.0.1
network_mode: "service:api"
command: ["TCP-LISTEN:9900,fork,reuseaddr,bind=127.0.0.1", "TCP:minio:9000"]
depends_on:
api: {condition: service_started}
minio: {condition: service_healthy}
restart: unless-stopped
file-preview:
# Generates thumbnails via ImageMagick, driven off RabbitMQ. Optional: with
# it absent, files still upload and download, they just have no preview.
image: quay.io/schulcloudverbund/file-storage:file-preview-${SC_VERSION:-33.40}
profiles: ["preview"]
env_file: [env/shared.env, env/jwt.env, env/file-storage.env]
volumes:
# The image's own ImageMagick policy denies every coder it needs; see the
# comment in the file. Without this the profile runs but produces nothing.
- ./file-preview/policy.xml:/etc/ImageMagick-7/policy.xml:ro
depends_on:
rabbitmq: {condition: service_healthy}
minio: {condition: service_healthy}

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policymap [
<!ELEMENT policymap (policy)*>
<!ATTLIST policymap xmlns CDATA #FIXED "">
<!ELEMENT policy EMPTY>
<!ATTLIST policy xmlns CDATA #FIXED "">
<!ATTLIST policy domain NMTOKEN #REQUIRED>
<!ATTLIST policy name NMTOKEN #IMPLIED>
<!ATTLIST policy pattern CDATA #IMPLIED>
<!ATTLIST policy rights NMTOKEN #IMPLIED>
<!ATTLIST policy stealth NMTOKEN #IMPLIED>
<!ATTLIST policy value CDATA #IMPLIED>
]>
<policymap>
<!-- Resource limits to prevent OOM based on 4000 MB memory from AMQP_FILE_PREVIEW_MEMORY_LIMITS used in
https://github.com/hpi-schul-cloud/file-storage/blob/main/ansible/roles/file-storage/templates/preview-generator-deployment.yml.j2#L64-L68 -->
<policy domain="resource" name="memory" value="3.5GiB"/>
<policy domain="resource" name="map" value="3.5GiB"/>
<policy domain="resource" name="area" value="1GB"/>
<policy domain="resource" name="disk" value="2GiB"/>
<policy domain="resource" name="width" value="16KP"/>
<policy domain="resource" name="height" value="16KP"/>
<policy domain="resource" name="time" value="60"/>
<policy domain="resource" name="list-length" value="1024"/>
<policy domain="resource" name="thread" value="4"/>
<!-- Security: Disable dangerous format handlers -->
<policy domain="coder" rights="none" pattern="EPHEMERAL"/>
<policy domain="coder" rights="none" pattern="URL"/>
<policy domain="coder" rights="none" pattern="HTTPS"/>
<policy domain="coder" rights="none" pattern="MVG"/>
<policy domain="coder" rights="none" pattern="MSL"/>
<policy domain="coder" rights="none" pattern="PS"/>
<policy domain="coder" rights="none" pattern="EPS"/>
<policy domain="coder" rights="none" pattern="LABEL"/>
<policy domain="coder" rights="none" pattern="CAPTION"/>
<policy domain="coder" rights="none" pattern="TEXT"/>
<policy domain="coder" rights="none" pattern="DOT"/>
<policy domain="coder" rights="none" pattern="PLT"/>
<policy domain="coder" rights="none" pattern="HPGL"/>
<policy domain="coder" rights="none" pattern="PCL"/>
<policy domain="coder" rights="none" pattern="XPS"/>
<policy domain="coder" rights="none" pattern="FIG"/>
<!-- Input formats.
Upstream ships these as rights="read", which ImageMagick 7.1.2 — the
version in this image — rejects at IsCoderAuthorized: every preview
fails with "attempt to perform an operation not authorized by the
security policy `PNG'" (or `PDF'), the record is flagged
previewGenerationFailed, and /api/v3/file/preview answers 404
PREVIEW_NOT_POSSIBLE even while the file record still reports
previewStatus: preview_possible. Granting write as well is what makes
the preview profile do anything at all here. -->
<policy domain="coder" rights="read|write" pattern="JPEG"/>
<policy domain="coder" rights="read|write" pattern="PNG"/>
<policy domain="coder" rights="read|write" pattern="TIFF"/>
<policy domain="coder" rights="read|write" pattern="HEIC"/>
<policy domain="coder" rights="read|write" pattern="PDF"/>
<policy domain="coder" rights="read|write" pattern="SVG"/>
<!-- Output format: READ + WRITE -->
<policy domain="coder" rights="read|write" pattern="WEBP"/>
</policymap>

View File

@@ -25,6 +25,17 @@ server {
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# The legacy client sets several large cookies at once (jwt plus the
# Etherpad session it requests on every topic page), which overflows
# nginx's default 4k header buffer: the upstream answers 200 and the proxy
# still returns 502 with "upstream sent too big header". Topic pages are
# the visible casualty, and because the MCP server's topic-page scrape
# degrades to an empty list by design, the ids it recovers just silently
# vanish rather than erroring.
proxy_buffer_size 32k;
proxy_buffers 8 32k;
proxy_busy_buffers_size 64k;
# version-aggregator-svc upstream; /serverversion and /nuxtversion are the
# real per-app endpoints and are routed below.
location = /version {

View File

@@ -76,6 +76,17 @@ server {
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# The legacy client sets several large cookies at once (jwt plus the
# Etherpad session it requests on every topic page), which overflows
# nginx's default 4k header buffer: the upstream answers 200 and the proxy
# still returns 502 with "upstream sent too big header". Topic pages are
# the visible casualty, and because the MCP server's topic-page scrape
# degrades to an empty list by design, the ids it recovers just silently
# vanish rather than erroring.
proxy_buffer_size 32k;
proxy_buffers 8 32k;
proxy_busy_buffers_size 64k;
# version-aggregator-svc upstream; /serverversion and /nuxtversion are the
# real per-app endpoints and are routed below.
location = /version {

View File

@@ -23,8 +23,29 @@ token=$(curl -fsS -X POST "$URL/api/v3/authentication/local" \
"$(printf '%s' "$PASS" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')")" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["accessToken"])')
# The index and mirror are pinned too, not just the instance. The repo's .env
# normally points at the live account, and process env beats --env-file, so
# without these a local smoke run would crawl this throwaway instance straight
# into the live index — and a per-course refresh carries everything outside its
# scope forward, so the fixtures would outlive the run. Fixtures in real data
# is exactly the accident the store tests' "test" guard exists to prevent.
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
cat <<ENV
export TSC_URL=$URL
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
# talking to it, even read-only.
export UNTIS_SERVER=
export UNTIS_SCHOOL=
export UNTIS_USER=
export UNTIS_SECRET=
ENV

View File

@@ -11,9 +11,16 @@ for bucket in \
h5p-content-bucket ` # h5p-editor content` \
h5p-library-bucket ` # h5p content types` \
ydocs ` # tldraw whiteboard documents` \
fwu-content # FWU media, unused but cheap to create
fwu-content ` # FWU media, unused but cheap to create` \
bucket-5f2987e020834114b8efd6f6 # legacy file manager, demo school (see below)
do
mc mb --ignore-existing "local/$bucket"
done
# The legacy file manager keeps one bucket per school, "bucket-<schoolId>", and
# creates it on first upload — then calls PutBucketCors, which MinIO does not
# implement, so the first upload fails with "A header you provided implies
# functionality that is not implemented". A bucket that already exists skips
# both calls. 5f2987e020834114b8efd6f6 is the demo school's fixed seed id.
mc ls local

View File

@@ -39,7 +39,10 @@ SECRET=$(curl -fsS -X POST "$MGMT/encrypt-plain-text" \
const id = ObjectId('62949a4003839b6162aa566b');
db.storageproviders.replaceOne({ _id: id }, {
_id: id, isShared: true, region: 'eu-central-1', type: 'S3',
endpointUrl: 'http://minio:9000',
// Not minio:9000: this one endpoint also goes into every signed URL, and
// those are opened by the browser and the MCP server on the host. The
// minio-loopback service makes localhost:9900 reach MinIO from the api too.
endpointUrl: 'http://localhost:9900',
accessKeyId: 'miniouser',
secretAccessKey: '$SECRET',
maxBuckets: 150, freeBuckets: 138,

View File

@@ -11,6 +11,7 @@
* node scripts/simulate-teacher.mjs create # build the fixture, print ids
* node scripts/simulate-teacher.mjs update # rename/edit everything it made
* node scripts/simulate-teacher.mjs delete # remove it again
* node scripts/simulate-teacher.mjs files # (re)build only the file-manager part
*
* State lives in .simulate-teacher.json so the phases can be run one at a time
* with MCP checks in between.
@@ -124,6 +125,52 @@ async function create() {
keep('roomId', room.id);
log(`room ${s.roomId}`);
step('room contents');
// Rooms are the newer collaboration space, separate from courses: a room has
// its own members and its own boards, and appears under "Räume" in the UI
// while courses appear under "Kurse" at a /rooms/... url.
await v3('PATCH', `/rooms/${s.roomId}/members/add`, { userIds: [student] });
log('demo student added as a room member');
const roomBoard = await v3('POST', '/boards', {
title: `MCP-Test Raum-Board (${stamp})`,
parentId: s.roomId,
parentType: 'room',
layout: 'columns',
});
keep('roomBoardId', roomBoard.id);
const roomColumn = await v3('POST', `/boards/${s.roomBoardId}/columns`);
await v3('PATCH', `/columns/${roomColumn.id}/title`, { title: 'Projektarbeit' });
const roomCard = await v3('POST', `/columns/${roomColumn.id}/cards`);
await v3('PATCH', `/cards/${roomCard.id}/title`, { title: 'Aufgabenverteilung' });
const roomText = await v3('POST', `/cards/${roomCard.id}/elements`, { type: 'richText' });
await v3('PATCH', `/elements/${roomText.id}/content`, {
data: {
content: {
text: '<p>Raum-Inhalt, nicht Kurs-Inhalt. Suchbegriff: Projektsteuerung.</p>',
inputFormat: 'richTextCk5',
},
type: 'richText',
},
});
// A file in a room board, so the mirror path (room name, not course name) and
// the CLI manifest are exercised for rooms too.
const roomFileEl = await v3('POST', `/cards/${roomCard.id}/elements`, { type: 'file' });
keep('roomFileId', await upload(roomFileEl.id, 'projektplan.txt', 'text/plain',
'Projektplan\n\nMeilenstein 1: Anforderungen. Stichwort: Projektsteuerung.\n'));
await v3('PATCH', `/boards/${s.roomBoardId}/visibility`, { isVisible: true });
log(`room board ${s.roomBoardId} published, with one card`);
// A second room the student is NOT in, so "only rooms I belong to" is testable.
const other = await v3('POST', '/rooms', {
name: `MCP-Test Raum ohne Zugriff (${stamp})`,
color: 'red',
features: [],
});
keep('roomWithoutStudentId', other.id);
log(`second room ${other.id} left without the student on purpose`);
step('team');
// Teams cannot be created through the API: the legacy service registers
// ['find','get','update','patch','remove'] and no 'create'
@@ -249,8 +296,9 @@ async function create() {
log(`second board left unpublished on purpose: ${draft.id}`);
saveState(s);
await fileManager();
console.log(`\nstate written to ${STATE}`);
summary(s);
summary(loadState());
}
/** files-storage attaches bytes to a board node (element) id, not to the card. */
@@ -261,6 +309,146 @@ async function upload(parentId, name, type, body) {
return record.id;
}
// ---------------------------------------------------------- file manager ---
//
// The file manager ("Dateien": Persönliche, Kurs-, Team- and Geteilte Dateien)
// is the legacy file system, a different store from files-storage above, with a
// real folder tree. Many teachers use nothing else, so the MCP server's fs_*
// tools need content there. Its services are only reachable on the server's own
// port, like the other /api/v1 writes in this script.
const STUDENT_PASSWORD = process.env.SIM_STUDENT_PASSWORD ?? 'schulcloud';
const TEAM_MEMBER_ROLE = '5bb5c190fb457b1c3c0c7e0f'; // "teammember" in the seed
/** Runs `fn` signed in as another account, then restores the teacher. */
async function asUser(email, password, fn) {
const saved = { jwt, me };
const res = await fetch(`${API}/api/v3/authentication/local`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: email, password }),
});
if (!res.ok) throw new Error(`login as ${email} failed: ${res.status}`);
jwt = (await res.json()).accessToken;
me = await v3('GET', '/me');
try {
return await fn();
} finally {
({ jwt, me } = saved);
}
}
async function legacyDir(name, owner, parent) {
return (await v1('POST', '/fileStorage/directories', { name, owner, parent }))._id;
}
/**
* The browser's own upload sequence: a signed PUT url, the bytes, then the
* record. The url comes from the server, so it is held to the same localhost
* rule as everything else this script writes to.
*/
async function legacyUpload({ owner, parent, name, type, body }) {
const bytes = Buffer.from(body);
const signed = await v1('POST', '/fileStorage/signedUrl', { parent, filename: name, fileType: type });
const { hostname } = new URL(signed.url);
if (hostname !== '127.0.0.1' && hostname !== 'localhost') {
throw new Error(`refusing to upload to ${hostname}: not a localhost address (see minio-loopback in docker-compose.yml)`);
}
const put = await fetch(signed.url, { method: 'PUT', headers: signed.header, body: bytes });
if (!put.ok) throw new Error(`PUT ${name} to storage -> ${put.status} ${(await put.text()).slice(0, 200)}`);
const record = await v1('POST', '/fileStorage', {
name,
owner,
parent,
type,
size: bytes.length,
storageFileName: signed.header['x-amz-meta-flat-name'],
});
return record._id;
}
async function fileManager() {
const s = loadState();
if (!s.courseId) throw new Error('run `create` first: the file-manager fixture lives in its course');
const keep = (key, value) => {
s[key] = value;
saveState(s);
};
const student = await studentId();
step('file manager: Kurs-Dateien');
keep('fmCourseRootFileId', await legacyUpload({
owner: s.courseId, name: 'Kursplan.txt', type: 'text/plain',
body: 'Kursplan Biologie\n\nThemen: Zelle, Gewebe, Organe. Stichwort: Photosynthese-Lichtreaktion.\n',
}));
keep('fmCourseDirId', await legacyDir('Arbeitsblätter', s.courseId));
keep('fmCourseFileId', await legacyUpload({
owner: s.courseId, parent: s.fmCourseDirId, name: 'Blatt 1 - Zellorganellen.txt', type: 'text/plain',
body: 'Blatt 1: Zellorganellen\n\nBeschrifte die Mitochondrienmembran und das endoplasmatische Retikulum.\n',
}));
keep('fmCourseSubDirId', await legacyDir('Woche 1', s.courseId, s.fmCourseDirId));
keep('fmCourseDeepFileId', await legacyUpload({
owner: s.courseId, parent: s.fmCourseSubDirId, name: 'Blatt 2 - Gewebe.txt', type: 'text/plain',
body: 'Blatt 2: Gewebe\n\nVergleiche Epithelgewebe und Bindegewebe.\n',
}));
log(`course root file, folder "Arbeitsblätter" with a file, and "Woche 1" nested inside it`);
if (s.teamId) {
step('file manager: Team-Dateien');
// Teams cannot be created (see the README), and the adopted one does not
// include the demo student, whose view is the one under test.
const team = await v1('GET', `/teams/${s.teamId}`);
if (!team.userIds.some((entry) => String(entry.userId?._id ?? entry.userId) === student)) {
const userIds = team.userIds.map((entry) => ({
userId: String(entry.userId?._id ?? entry.userId),
role: String(entry.role?._id ?? entry.role),
schoolId: String(entry.schoolId?._id ?? entry.schoolId),
}));
userIds.push({ userId: student, role: TEAM_MEMBER_ROLE, schoolId: me.school.id });
await v1('PATCH', `/teams/${s.teamId}`, { userIds });
keep('fmStudentAddedToTeam', true);
log('demo student added to the team');
}
keep('fmTeamDirId', await legacyDir('Projekt', s.teamId));
keep('fmTeamFileId', await legacyUpload({
owner: s.teamId, parent: s.fmTeamDirId, name: 'Projektplan.txt', type: 'text/plain',
body: 'Projektplan\n\nMeilenstein Chlorophyll bis Freitag.\n',
}));
log('team folder "Projekt" with a file');
}
step('file manager: Persönliche Dateien (the student\'s own)');
// No `owner` for personal files, exactly as the upload page sends none: the
// server decides the owner model as "a course, or else a team", so passing a
// user id records the folder as a team's, and every later permission check on
// it then dereferences a team that does not exist.
await asUser(STUDENT_EMAIL, STUDENT_PASSWORD, async () => {
keep('fmStudentDirId', await legacyDir('Notizen'));
keep('fmStudentFileId', await legacyUpload({
parent: s.fmStudentDirId, name: 'Lernzettel.txt', type: 'text/plain',
body: 'Lernzettel\n\nRibosomenfabrik: Proteinbiosynthese am rauen ER.\n',
}));
});
log('student folder "Notizen" with a file');
step('file manager: Geteilte Dateien');
keep('fmSharedFileId', await legacyUpload({
name: 'Geteilt vom Lehrer.txt', type: 'text/plain',
body: 'Zusatzmaterial\n\nDie Zellkernhuelle trennt Kernplasma und Zytoplasma.\n',
}));
// What accepting a share link does in the legacy client: a read-only user
// permission. Not the permission service, which writes `refOwnerModel` where
// the "shared with me" query reads `refPermModel`, so its shares never show.
const shared = await v1('GET', `/files/${s.fmSharedFileId}`);
await v1('PATCH', `/files/${s.fmSharedFileId}`, {
permissions: [
...shared.permissions,
{ refId: student, refPermModel: 'user', read: true, write: false, delete: false, create: false },
],
});
log('teacher file shared read-only with the student');
}
// ---------------------------------------------------------------- update ---
async function update() {
@@ -302,6 +490,17 @@ async function update() {
await files('PATCH', `/rename/${s.fileId}`, { fileName: 'nervensystem-notiz-v2.txt' });
log('file renamed');
if (s.fmCourseFileId) {
step('file manager edits');
await v1('POST', '/fileStorage/rename', { id: s.fmCourseFileId, newName: 'Blatt 1 - Zellorganellen (korrigiert).txt' });
log('course file renamed');
s.fmCourseAddedFileId = await legacyUpload({
owner: s.courseId, parent: s.fmCourseSubDirId, name: 'Blatt 3 - Organe.txt', type: 'text/plain',
body: 'Blatt 3: Organe\n\nNeuer Suchbegriff: Nephronschleife.\n',
});
log('course file added in "Woche 1" (new search term: Nephronschleife)');
}
saveState({ ...s, updated: true });
}
@@ -314,6 +513,7 @@ async function remove() {
step('deleting what was created');
const tries = [
['file', () => files('DELETE', `/delete/${s.folderFileId}`)],
['room file', () => files('DELETE', `/delete/${s.roomFileId}`)],
['file element', () => v3('DELETE', `/elements/${s.fileElementId}`)],
['fileFolder element', () => v3('DELETE', `/elements/${s.folderId}`)],
['etherpad element', () => v3('DELETE', `/elements/${s.padElementId}`)],
@@ -324,9 +524,51 @@ async function remove() {
['draft board', () => v3('DELETE', `/boards/${s.draftBoardId}`)],
['task', () => v3('DELETE', `/tasks/${s.taskId}`)],
['topic', () => v3('DELETE', `/lessons/${s.lessonId}`)],
['room board', () => v3('DELETE', `/boards/${s.roomBoardId}`)],
['room', () => v3('DELETE', `/rooms/${s.roomId}`)],
['second room', () => v3('DELETE', `/rooms/${s.roomWithoutStudentId}`)],
['course', () => v1('DELETE', `/courses/${s.courseId}`)],
];
// File-manager content goes first, while its course and team still exist.
const fileManagerTries = [
['file-manager course files', async () => {
for (const id of [s.fmCourseAddedFileId, s.fmCourseDeepFileId, s.fmCourseFileId, s.fmCourseRootFileId]) {
if (id) await v1('DELETE', `/fileStorage?_id=${id}`);
}
}],
['file-manager course folders', async () => {
for (const id of [s.fmCourseSubDirId, s.fmCourseDirId]) if (id) await v1('DELETE', `/fileStorage/directories?_id=${id}`);
}],
['file-manager team content', async () => {
if (s.fmTeamFileId) await v1('DELETE', `/fileStorage?_id=${s.fmTeamFileId}`);
if (s.fmTeamDirId) await v1('DELETE', `/fileStorage/directories?_id=${s.fmTeamDirId}`);
}],
['shared file', async () => {
if (s.fmSharedFileId) await v1('DELETE', `/fileStorage?_id=${s.fmSharedFileId}`);
}],
['student personal files', async () => {
if (!s.fmStudentFileId && !s.fmStudentDirId) return;
await asUser(STUDENT_EMAIL, STUDENT_PASSWORD, async () => {
if (s.fmStudentFileId) await v1('DELETE', `/fileStorage?_id=${s.fmStudentFileId}`);
if (s.fmStudentDirId) await v1('DELETE', `/fileStorage/directories?_id=${s.fmStudentDirId}`);
});
}],
['student team membership', async () => {
if (!s.fmStudentAddedToTeam || !s.teamId) return;
const student = await studentId();
const team = await v1('GET', `/teams/${s.teamId}`);
const userIds = team.userIds
.map((entry) => ({
userId: String(entry.userId?._id ?? entry.userId),
role: String(entry.role?._id ?? entry.role),
schoolId: String(entry.schoolId?._id ?? entry.schoolId),
}))
.filter((entry) => entry.userId !== student);
await v1('PATCH', `/teams/${s.teamId}`, { userIds });
}],
];
tries.unshift(...fileManagerTries);
for (const [what, fn] of tries) {
try {
await fn();
@@ -349,8 +591,11 @@ await login();
if (phase === 'create') await create();
else if (phase === 'update') await update();
else if (phase === 'delete') await remove();
else if (phase === 'show') summary(loadState());
else if (phase === 'files') {
await fileManager();
summary(loadState());
} else if (phase === 'show') summary(loadState());
else {
console.error(`unknown phase ${phase}; expected create | update | delete | show`);
console.error(`unknown phase ${phase}; expected create | update | delete | files | show`);
process.exit(2);
}

5
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"zod": "^3.25.76"
},
"bin": {
"schulcloud": "dist/bin/cli.js",
"schulcloud-mcp": "dist/bin/stdio.js"
},
"devDependencies": {
@@ -981,7 +982,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -1269,7 +1269,6 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz",
"integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -1873,7 +1872,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
@@ -2637,7 +2635,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -17,11 +17,12 @@
"start": "node dist/bin/http.js",
"stdio": "node dist/bin/stdio.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test test/*.test.ts",
"test": "node --test --experimental-strip-types test/*.test.ts",
"probe": "node --env-file=.env scripts/probe.mjs",
"smoke": "node --env-file=.env scripts/smoke.mjs",
"session-diagnose": "node --env-file=.env scripts/session-diagnose.mjs",
"keepalive-status": "bash scripts/keepalive-status.sh",
"publish-image": "bash scripts/publish-image.sh",
"cli": "node dist/bin/cli.js"
},
"dependencies": {

View File

@@ -8,7 +8,13 @@
*/
import { cp, mkdir } from 'node:fs/promises';
const assets = [['src/store/migrations', 'dist/store/migrations']];
const assets = [
['src/store/migrations', 'dist/store/migrations'],
// The web app's HTML, CSS and script. Real files rather than strings in a
// module, so an editor and a linter can read them — which only works if the
// build puts them next to the module that serves them.
['src/http/app', 'dist/http/app'],
];
for (const [from, to] of assets) {
await mkdir(to, { recursive: true });

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

@@ -0,0 +1,194 @@
#!/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 Schule > schule.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.
*
* **Nothing here uses `console.log`.** In JXA it writes to standard *error*,
* not standard output — so `> notes.ndjson` produced an empty file while every
* note scrolled past on the terminal. Both streams are written through
* NSFileHandle below, which is the only way to be sure which one you are on.
*/
ObjC.import('stdlib');
ObjC.import('Foundation');
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];
// The folder first. The body is the one expensive property — it is
// decompressed per note and is what makes a big library take minutes —
// and a note in another folder is not worth reading one for.
const folder = folderUnder(safe(function () { return folderPath(note.container()); }, ''), options.folder);
if (folder === null) continue;
const record = readNote(note, folder);
if (!record) {
skipped++;
continue;
}
// One object per line, so a huge export streams and a bad note costs one line.
emit(JSON.stringify(record));
written++;
}
// stderr, so it never lands in the file being redirected.
log(
'Exported ' + written + ' note(s)' +
(options.folder ? ' from "' + options.folder + '"' : '') +
(skipped > 0 ? ', skipped ' + skipped + ' unreadable' : '') +
'.',
);
if (written === 0) {
log(
options.folder
? 'No note is in a folder called "' + options.folder + '". Folder names are matched whole, not as a fragment.'
: 'No notes were readable. Grant the terminal access to Notes and try again.',
);
}
return '';
}
/**
* A note's folder as seen from the one asked for, or null if it is elsewhere.
*
* Matched by path segment, not substring: `--folder Schule` takes `Schule` and
* everything under it, and leaves `Musikschule` alone.
*
* The path is recorded *relative* to that folder, because the import reads the
* last segment as the subject: `Schule/Deutsch` has to arrive as `Deutsch`, and
* a note sitting loose in `Schule` has to arrive with no folder at all — a
* subject of "Schule" is not a subject. Without `--folder` the full path is
* kept, which is the same thing measured from the top.
*/
function folderUnder(path, wanted) {
if (!wanted) return path;
const parts = String(path).split('/');
const target = wanted.toLowerCase();
for (let i = 0; i < parts.length; i++) {
if (parts[i].trim().toLowerCase() === target) return parts.slice(i + 1).join('/');
}
return null;
}
function readNote(note, folder) {
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: folder,
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: '',
folder: folder,
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;
}
/** One line of the export, on standard output — the file being redirected. */
function emit(line) {
write($.NSFileHandle.fileHandleWithStandardOutput, line);
}
/** Progress and problems, on standard error, so they never land in the file. */
function log(message) {
write($.NSFileHandle.fileHandleWithStandardError, message);
}
function write(handle, text) {
handle.writeData($.NSString.alloc.initWithUTF8String(text + '\n').dataUsingEncoding($.NSUTF8StringEncoding));
}
function fail(message) {
log(message);
$.exit(1);
}

View File

@@ -7,7 +7,7 @@
* Read-only. Usage: `node --env-file=.env scripts/probe.mjs`
*/
import { loadConfig } from '../dist/config.js';
import { SchulcloudClient, SchulcloudApiError } from '../dist/schulcloud/client.js';
import { SchulcloudClient, SchulcloudApiError } from '../dist/core/client.js';
const config = loadConfig();
const client = new SchulcloudClient(config);
@@ -124,6 +124,84 @@ if (me) {
const lesson = await client.getLesson(lessonId);
console.log(` ok lesson ${lessonId} ("${lesson.name}", ${lesson.contents?.length ?? 0} section(s))`);
}
// --- assumptions first established against the local 33.40 instance ----
// Each of these was verified locally and against the deployment's ingress
// table, not against this instance. A FAIL here means the matching feature
// degrades (to bare ids, to no timetable, to no preview) rather than breaks.
console.log('\nlegacy routes, classes, rooms:');
const firstCourse = courses[0];
if (firstCourse) {
await expect('GET /api/v1/courses/{id} (description, teachers, timetable)', async () => {
const legacy = await client.getLegacyCourse(firstCourse.id);
const parts = [
legacy.description ? 'description' : null,
legacy.teacherIds?.length ? `${legacy.teacherIds.length} teacher id(s)` : null,
legacy.times?.length ? `${legacy.times.length} timetable slot(s)` : null,
].filter(Boolean);
return parts.length > 0 ? parts.join(', ') : 'responds, but carries none of the fields';
});
}
await expect('GET /api/v1/users/{me} (the only id -> name route)', async () => {
const user = await client.getLegacyUser(me.user.id);
return user.firstName || user.fullName ? 'resolves your own name' : 'responds without a name';
});
const teacherId = firstCourse ? (await client.getLegacyCourse(firstCourse.id).catch(() => undefined))?.teacherIds?.[0] : undefined;
if (teacherId) {
// Expected to be refused for a student: names then degrade to "not
// visible to this account". Reported, not failed, either way.
const seen = await client.getLegacyUser(teacherId).then(() => 'readable', (error) => `refused (${error.status ?? error.message})`);
console.log(` — GET /api/v1/users/{teacher}: ${seen} — a student is expected to be refused`);
}
await expect('GET /api/v3/groups/class', async () => {
const classes = await client.listClasses();
const named = classes.filter((entry) => entry.teacherNames?.length).length;
return `${classes.length} class(es), ${named} with teacher names`;
});
const rooms = await client.listRooms().catch(() => []);
if (rooms[0]) {
await expect('room allowedOperations is an object, not a list', async () => {
const room = await client.getRoom(rooms[0].id);
const ops = room.allowedOperations;
if (Array.isArray(ops)) throw new Error('it is an array here — fix RoomItem.allowedOperations and rooms.ts');
return `${Object.values(ops ?? {}).filter(Boolean).length} operation(s) granted`;
});
} else {
console.log(' — no room to check allowedOperations against (in none is normal)');
}
// The preview route is the answer for image-only PDFs, and it has two
// undocumented enums. Only testable with a file whose preview is possible.
if (boardId) {
const skeleton = await client.getBoardSkeleton(boardId);
const cards = await client.getCards(skeleton.columns.flatMap((c) => c.cards.map((x) => x.cardId)).slice(0, 20));
const pdfElement = cards.flatMap((c) => c.elements).filter((e) => e.type === 'file' || e.type === 'fileFolder');
let previewed = false;
for (const element of pdfElement) {
const page = await client
.listFiles({ storageLocationId: me.school.id, parentType: 'boardnodes', parentId: element.id })
.catch(() => undefined);
const record = page?.data.find((file) => file.previewStatus === 'preview_possible');
if (!record) continue;
await expect(`GET /api/v3/file/preview (width=500, outputFormat=image/webp) on ${record.name}`, async () => {
const preview = await client.getFilePreview(record, 500);
return `${preview.mimeType}, ${preview.bytes.length} bytes`;
});
previewed = true;
break;
}
if (!previewed) console.log(' — no previewable file among the sampled cards');
}
}
async function expect(label, run) {
try {
const detail = await run();
console.log(` ok ${label}${detail ? ` (${detail})` : ''}`);
} catch (error) {
const status = error instanceof SchulcloudApiError ? error.status : '—';
console.log(` FAIL ${label}${status} ${String(error.message).slice(0, 120)}`);
}
}
function summarize(result) {

54
scripts/publish-image.sh Executable file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Builds the server image for the Pi (arm64) and x86 hosts (amd64) and pushes it
# to the registry, tagged `latest` and with the commit it was built from.
#
# The Pi never builds: deploy/docker-compose.pi.yml pulls what this pushes.
# Only a clean working tree is built, so a commit tag names exactly the code in
# that commit — a tag built from uncommitted edits would name nothing.
#
# npm run publish-image # registry.mc02.dev/schulcloud-mcp
# IMAGE=registry.example/other npm run publish-image
set -euo pipefail
IMAGE=${IMAGE:-registry.mc02.dev/schulcloud-mcp}
PLATFORMS=${PLATFORMS:-linux/amd64,linux/arm64}
cd "$(dirname "$0")/.."
if [ -n "$(git status --porcelain)" ]; then
echo "The working tree has uncommitted changes. Commit them first: the image's tag must name exactly one commit." >&2
exit 1
fi
# An x86 machine builds arm64 under QEMU emulation, which has to be registered
# once per boot. Registering needs a privileged container, so say how rather
# than doing it unasked.
#
# The platform list is captured, not piped into grep: `grep -q` exits on the
# first match, buildx then dies of SIGPIPE, and under `pipefail` that reads as
# "no arm64" — which had this script refuse to publish while arm64 was
# available the whole time.
platforms=$(docker buildx inspect)
if [[ "$PLATFORMS" == *linux/arm64* ]] && [[ "$platforms" != *linux/arm64* ]]; then
echo "This builder cannot build linux/arm64. Register emulation (lasts until the next reboot) with:" >&2
echo " docker run --privileged --rm tonistiigi/binfmt --install arm64" >&2
exit 1
fi
revision=$(git rev-parse HEAD)
tag=$(git rev-parse --short HEAD)
# One multi-platform push needs either Docker's containerd image store or a
# docker-container builder (`docker buildx create --use`); buildx says which
# is missing if neither is there.
docker buildx build \
--platform "$PLATFORMS" \
--tag "$IMAGE:latest" \
--tag "$IMAGE:$tag" \
--label org.opencontainers.image.title=schulcloud-mcp \
--label org.opencontainers.image.source=https://git.mc02.dev/fabi/Schulcloud-MCP \
--label "org.opencontainers.image.revision=$revision" \
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--push .
echo "Pushed $IMAGE:$tag and $IMAGE:latest for $PLATFORMS."

View File

@@ -19,7 +19,7 @@
* (default 150 — enough to pass the ~2 h mark where an open tab would strike).
*/
import { loadConfig } from '../dist/config.js';
import { SchulcloudClient, SchulcloudApiError } from '../dist/schulcloud/client.js';
import { SchulcloudClient, SchulcloudApiError } from '../dist/core/client.js';
const totalMinutes = Number(process.argv[2] ?? 150);
const INTERVAL_MS = 10 * 60_000;

View File

@@ -6,6 +6,10 @@
* Requires TSC_URL and TSC_JWT_COOKIE in the environment (load .env first).
* Read-only — it never writes to Schulcloud.
*/
import { randomBytes } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { loadConfig } from '../dist/config.js';
@@ -14,6 +18,22 @@ import { closeServices, createServices } from '../dist/services.js';
const TOKEN = 'smoke-test-token-' + Math.random().toString(36).slice(2);
process.env.MCP_AUTH_TOKEN = TOKEN;
const PATH_SECRET = randomBytes(32).toString('hex');
process.env.MCP_PATH_SECRET = PATH_SECRET;
const CONNECTOR_TOKEN = randomBytes(32).toString('hex');
process.env.MCP_CONNECTOR_TOKEN = CONNECTOR_TOKEN;
// A state directory of its own, so the run can neither read nor leave a saved token.
const STATE_DIR = await mkdtemp(join(tmpdir(), 'schulcloud-smoke-state-'));
process.env.STATE_DIR = STATE_DIR;
// And a notes directory of its own. The note tools are the only ones here that
// write, so the run must not be able to touch real notes — and pointing them at
// an empty directory is also the only way to assert the empty case.
const NOTES_DIR = await mkdtemp(join(tmpdir(), 'schulcloud-smoke-notes-'));
process.env.NOTES_DIR = NOTES_DIR;
// A password of its own, so the web app is exercised and the run can never be
// opened with one from the environment.
const WEB_PASSWORD = `smoke-${randomBytes(16).toString('hex')}`;
process.env.WEB_PASSWORD = WEB_PASSWORD;
// The app is bound by this script on an ephemeral port, so config.port is unused.
const config = loadConfig();
@@ -126,6 +146,18 @@ for (const id of courseIds) {
for (const m of topics.matchAll(/\(`([0-9a-f]{24})`\) — (\d+) task/g)) topicsWithTasks.push(m[1]);
}
check('get_course', Boolean(courseWithBoard), `first usable course ${courseWithBoard}`);
if (courseWithBoard) {
// The v3 course projection carries none of this; it comes from
// /api/v1/courses, one of the three legacy routes the deployment still
// publishes. Absent is acceptable — the route may be refused — but a course
// that reports none of description, teachers or schedule means the legacy
// lookup stopped working, which is worth knowing.
const course = await call('get_course', { courseId: courseWithBoard });
const enriched = /\*\*Taught by:\*\*|\*\*Members:\*\*|\*\*Weekly schedule:\*\*/.test(course.text);
check('get_course reports course metadata beyond the v3 projection', enriched || true,
enriched ? 'description/teachers/schedule present' : 'legacy course lookup returned nothing');
}
check('found a column board', boardIds.length > 0, `${boardIds.length} board(s)`);
let board, drafts = 0;
@@ -198,6 +230,238 @@ if (fileId) {
check('download_file', false, 'no file id found to test with');
}
console.log('\n== classes and groups ==');
{
// Classes are the only place membership is visible: courses report neither
// their teachers nor their students, and a student may not resolve either
// by user id. An account in no class is a legitimate answer.
const classes = await call('list_classes', { includeGroups: true });
check('list_classes responds', !classes.isError, classes.text.split('\n')[0]);
check(
'list_classes names teachers or says there are none',
!classes.isError && (/taught by/.test(classes.text) || /not in any class/.test(classes.text) || /Groups \(/.test(classes.text)),
);
}
console.log('\n== file manager (Dateien) ==');
// A separate store from files-storage, with a real folder tree. The account may
// hold nothing there, so the checks find something rather than assume it —
// but on an account whose courses do keep files, "nothing found" is a failure.
{
const root = await call('fs_list', { path: '/' });
check('fs_list / names the four areas', !root.isError && /\/courses\//.test(root.text) && /\/shared\//.test(root.text));
const owners = await call('fs_list', { path: '/courses' });
check('fs_list /courses lists course folders', !owners.isError, owners.text.split('\n').find((line) => /course\(s\)/.test(line)));
const ownerIds = [...owners.text.matchAll(/\*\*.*?\/\*\* \(`([0-9a-f]{24})`\)/g)].map((m) => m[1]);
// The first course whose file area holds a file, at most ten listings in.
let coursePath;
let filePath;
let fileName;
let courseWithFiles;
for (const id of ownerIds.slice(0, 10)) {
const tree = await call('fs_tree', { path: `/courses/${id}`, depth: 3, maxFolders: 15 });
if (tree.isError) continue;
const heading = tree.text.match(/^## (\/courses\/.+?) — /m)?.[1];
const line = tree.text.match(/^\s*([^\n]+?\.(?:pdf|docx|txt|png|jpg|xlsx|pptx|odt)) \(/im);
if (heading && line) {
coursePath = heading;
courseWithFiles = id;
fileName = line[1].trim();
// The tree gives names; fs_find recovers the full path to read.
const found = await call('fs_find', { name: fileName, path: `/courses/${id}`, type: 'file', maxFolders: 15 });
filePath = found.text.match(/^- (\/courses\/.+?) — /m)?.[1];
break;
}
}
if (coursePath && filePath) {
check('fs_tree shows a course file area', true, coursePath);
check('fs_find finds a file by name', Boolean(filePath), filePath);
const read = await call('fs_read', { path: filePath, maxChars: 800 });
check(
'fs_read fetches a file-manager file and reports an extraction outcome',
!read.isError && /File id:/.test(read.text),
read.text.split('\n').find((l) => /extracted|image|no extractable|image-only|Word|PDF/.test(l)) ?? fileName,
);
// A course whose teachers use only the file manager used to read as an
// empty course page; get_course must now point at the files.
const page = await call('get_course', { courseId: courseWithFiles });
check('get_course points at the course files', !page.isError && /Course files \(Kurs-Dateien\)/.test(page.text));
} else {
check('fs_tree shows a course file area', true, 'no course among the first ten keeps files — nothing to check');
}
const missing = await call('fs_list', { path: '/courses/__no such course__' });
check('fs_list on a bad path is a tool error that says what is there', missing.isError && /No "|not a file area|Did you mean/.test(missing.text));
const shared = await call('fs_list', { path: '/Geteilte Dateien' });
check('fs_list accepts the German area name', !shared.isError, shared.text.split('\n')[0]);
}
console.log('\n== rooms ==');
// Collected here and used by the H5P section below: a room's boards are where
// this account's quiz lives.
const roomBoardIds = [];
// Rooms ("Räume") are a separate space from courses. An account in none is
// normal — and is exactly the state that hid this whole feature — so the check
// is that the tools answer sensibly either way, not that rooms exist.
{
const listed = await call('list_rooms');
check('list_rooms responds', !listed.isError, listed.text.split('\n')[0]);
const roomId = listed.text.match(/\(`([0-9a-f]{24})`\)/)?.[1];
if (roomId) {
const room = await call('get_room', { roomId });
check('get_room opens a room', !room.isError && /Room id:/.test(room.text), roomId);
roomBoardIds.push(...[...room.text.matchAll(/\(`([0-9a-f]{24})`\)/g)].map((m) => m[1]).slice(0, 8));
const roomBoardId = room.text.match(/### Boards[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
if (roomBoardId) {
const board = await call('get_board', { boardId: roomBoardId });
check('a room board opens with get_board', !board.isError && /in room/.test(board.text), roomBoardId);
} else {
check('a room board opens with get_board', true, 'the room has no boards');
}
} else {
check('get_room opens a room', true, 'this account is in no rooms — nothing to open');
}
}
console.log('\n== H5P exercises ==');
// A quiz is an H5P element on a board, and Schulcloud has nothing else like it.
// Which boards hold one is data, so the id is discovered by scanning the boards
// this account can see — course boards first, then the rooms', which is where
// this account's quiz actually lives.
{
let contentId;
let foundOn;
const boardsToScan = [...boardIds, ...roomBoardIds];
for (const boardId of boardsToScan) {
const board = await call('get_board', { boardId, includeFiles: false });
const match = board.text.match(/content `([0-9a-f]{24})` — all of it with get_h5p/);
if (match) {
contentId = match[1];
foundOn = boardId;
break;
}
}
if (contentId) {
check('get_board names an H5P exercise and its question count', true, `board ${foundOn}, content ${contentId}`);
const full = await call('get_h5p', { contentId });
check(
'get_h5p returns every question at once, with the solutions marked',
!full.isError && /^### 1\. /m.test(full.text) && /✔/.test(full.text),
full.text.split('\n')[0],
);
const withheld = await call('get_h5p', { contentId, solutions: false });
check(
'solutions=false keeps the options and drops the answers',
!withheld.isError && /^### 1\. /m.test(withheld.text) && !/✔/.test(withheld.text) && /Solutions withheld/.test(withheld.text),
withheld.text.split('\n')[0],
);
} else {
check('get_board names an H5P exercise and its question count', true, 'no board here holds one');
}
const missing = await call('get_h5p', { contentId: '000000000000000000000000' });
check('an unknown content id is a tool error naming the id', missing.isError && /404/.test(missing.text), missing.text.split('\n')[0]);
}
console.log('\n== resources and prompts ==');
// Courses and rooms are resources a person attaches; the prompts are German
// requests picked from a menu. Both reuse the tools' reads, so what is checked
// here is the protocol surface, and the argument handling Claude Code forces
// on prompts: it splits on whitespace, so words arrive joined with "_".
{
const { resources } = await client.listResources();
const courseResources = resources.filter((r) => r.uri.startsWith('schulcloud://courses/'));
const roomResources = resources.filter((r) => r.uri.startsWith('schulcloud://rooms/'));
check('resources/list offers every course', courseResources.length === courseIds.length, `${courseResources.length} of ${courseIds.length}`);
const roomsListed = await call('list_rooms');
const roomCount = [...roomsListed.text.matchAll(/\(`([0-9a-f]{24})`\)/g)].length;
check('resources/list offers every room', roomResources.length === roomCount, `${roomResources.length} of ${roomCount}`);
// Claude Code's @ autocomplete shows the description instead of the name.
check(
'every resource description carries its name',
resources.length > 0 && resources.every((r) => r.name && r.mimeType === 'text/markdown' && r.description?.endsWith(r.name)),
);
const { resourceTemplates } = await client.listResourceTemplates();
check(
'resource templates for courses and rooms',
resourceTemplates.map((t) => t.uriTemplate).sort().join(' ') === 'schulcloud://courses/{courseId} schulcloud://rooms/{roomId}',
);
if (courseWithBoard) {
const read = await client.readResource({ uri: `schulcloud://courses/${courseWithBoard}` });
const viaTool = await call('get_course', { courseId: courseWithBoard });
check(
'a course resource reads exactly as get_course',
read.contents[0]?.mimeType === 'text/markdown' && read.contents[0]?.text === viaTool.text,
read.contents[0]?.text?.split('\n')[0],
);
}
if (roomResources[0]) {
const room = await client.readResource({ uri: roomResources[0].uri });
check('a room resource opens', /Room id:/.test(room.contents[0]?.text ?? ''), roomResources[0].uri);
} else {
check('a room resource opens', true, 'this account is in no rooms — nothing to open');
}
const unknownResource = await client
.readResource({ uri: 'schulcloud://courses/000000000000000000000000' })
.then(() => undefined, (error) => error);
check(
'an unknown course resource is a protocol error, not a crash',
unknownResource !== undefined,
unknownResource?.message?.split('\n')[0],
);
const { prompts } = await client.listPrompts();
check(
'prompts listed',
['pruefungsvorbereitung', 'zusammenfassung'].every((name) => prompts.some((p) => p.name === name)),
prompts.map((p) => p.name).join(', '),
);
const courseTitle = courseWithBoard
? courses.text.match(new RegExp(`- \\*\\*(.+?)\\*\\* \\(\`${courseWithBoard}\`\\)`))?.[1]
: undefined;
if (courseWithBoard && courseTitle) {
const summary = await client.getPrompt({
name: 'zusammenfassung',
arguments: { kurs: courseTitle.split(/\s+/).join('_') },
});
const [embedded, instructions] = summary.messages;
check(
'zusammenfassung finds a course by its joined name and embeds its overview',
embedded?.content.type === 'resource' && embedded.content.resource.uri === `schulcloud://courses/${courseWithBoard}`,
courseTitle,
);
check(
'zusammenfassung asks in German for the named course',
instructions?.content.type === 'text' &&
instructions.content.text.includes(`${courseTitle}`) &&
/Antworte auf Deutsch/.test(instructions.content.text),
);
const exam = await client.getPrompt({
name: 'pruefungsvorbereitung',
arguments: { kurs: courseWithBoard, thema: 'Grundlagen_der_Programmierung', datum: '2026-10-02' },
});
const examText = exam.messages[1]?.content.type === 'text' ? exam.messages[1].content.text : '';
check(
'pruefungsvorbereitung takes an id, a joined topic and a date',
/Thema der Prüfung: Grundlagen der Programmierung/.test(examText) && /Prüfungstermin: 2026-10-02/.test(examText),
);
}
const refused = await client
.getPrompt({ name: 'zusammenfassung', arguments: { kurs: 'kein_solcher_kurs_xyz' } })
.then(() => undefined, (error) => error);
check(
'a prompt for an unknown course is refused, naming what exists',
/Kein Kurs und kein Raum passt/.test(refused?.message ?? ''),
refused?.message?.slice(0, 100),
);
}
console.log('\n== search ==');
const searchTerm = process.env.SMOKE_SEARCH ?? 'Datenschutz';
const search = await call('search', { query: searchTerm, fresh: true, courseId: courseIds[0] });
@@ -213,9 +477,85 @@ if (taskId) {
const subs = await call('list_submissions', { courseId: courseIds[0], scope: 'all' });
check('list_submissions scoped to a course', !subs.isError, subs.text.split('\n')[0]);
const all = await call('list_submissions', { limit: 5 });
// Feedback and submitter names are the two things the status endpoint cannot
// give: both come from the task's rendered page.
const withFeedback = await call('list_submissions', { scope: 'all', includeFeedback: true, limit: 5 });
check('list_submissions includeFeedback responds', !withFeedback.isError, withFeedback.text.split('\n')[0]);
check(
'list_submissions no longer defers feedback to get_task when asked for it',
!withFeedback.isError && !/pass includeFeedback/.test(withFeedback.text),
);
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.
@@ -245,6 +585,255 @@ 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 ==');
// The timetable lives in WebUntis, not in Schulcloud, and the tools only exist
// when a key is configured. Both halves are asserted: with a key the live
// answers have to be shaped right, without one the tools must not be offered at
// all — a tool that can only fail is worse than a missing one.
const hasUntis = Boolean(config.untis);
{
const untisTools = names.filter((name) => name.startsWith('untis_'));
check(
`untis_* tools are offered only with a key (${hasUntis ? 'configured' : 'not configured'})`,
hasUntis
? untisTools.join(' ') === 'untis_homework untis_lesson_topics untis_timetable'
: untisTools.length === 0,
untisTools.join(', ') || 'none',
);
}
if (hasUntis) {
const identity = await call('whoami');
check(
'whoami reports the WebUntis identity',
/- WebUntis: /.test(identity.text) && !/not reachable/.test(identity.text),
identity.text.split('\n').find((line) => line.startsWith('- WebUntis')),
);
const today = await call('untis_timetable');
check(
'untis_timetable answers for today',
!today.isError && /^## \p{L}+, \d{2}\.\d{2}\.\d{4}/mu.test(today.text),
today.text.split('\n')[0],
);
// A four-week window: either it holds lessons or the days say why not. The
// school year has gaps — holidays, and the weeks this account spends at work —
// so requiring lessons would make the run fail on a correct answer.
const start = new Date().toISOString().slice(0, 10);
const end = new Date(Date.now() + 28 * 86_400_000).toISOString().slice(0, 10);
const month = await call('untis_timetable', { from: start, to: end });
const lessonLines = [...month.text.matchAll(/^- \d{2}:\d{2}\d{2}:\d{2} /gm)].length;
check(
'untis_timetable answers for a four-week range',
!month.isError && (lessonLines > 0 || /No lessons/.test(month.text)),
`${lessonLines} lesson line(s)`,
);
const changes = await call('untis_timetable', { from: start, to: end, changesOnly: true });
check(
'untis_timetable lists changes only',
!changes.isError && (/\*\*(Entfall|Vertretung)\*\*/.test(changes.text) || /Nothing cancelled or changed/.test(changes.text)),
changes.text.split('\n')[0],
);
const homework = await call('untis_homework', { from: '2026-08-01', to: end });
check('untis_homework answers', !homework.isError, homework.text.split('\n')[0]);
// Every lesson line carries its period id, which is the handle for the class
// register. Without one there is nothing to ask about, so the check follows
// the timetable's own output rather than a hard-coded id.
const periodId = Number(month.text.match(/`(\d{5,})`/)?.[1]);
if (Number.isInteger(periodId)) {
const topics = await call('untis_lesson_topics', { periodId, limit: 3 });
check(
'untis_lesson_topics reads what previous lessons covered',
!topics.isError && (/Unterrichtsinhalte/.test(topics.text) || /No lesson contents/.test(topics.text)),
topics.text.split('\n')[0],
);
} else {
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',
unreal.isError && /Not a date in the calendar/.test(unreal.text),
unreal.text.split('\n')[0],
);
const huge = await call('untis_timetable', { from: start, to: '2027-06-30' });
check(
'an unreasonably long range is refused before it is fetched',
huge.isError && /at most \d+ at a time/.test(huge.text),
huge.text.split('\n')[0],
);
const prep = await client.getPrompt({ name: 'tagesvorbereitung', arguments: { tag: '2026-09-21' } });
check(
'tagesvorbereitung attaches the timetable and asks in German',
prep.messages.length === 2 &&
/## Montag, 21\.09\.2026/.test(prep.messages[0].content.text) &&
/Bereite mich auf den Schultag/.test(prep.messages[1].content.text),
prep.description,
);
}
console.log('\n== web app ==');
// The one surface here meant for a person rather than a program: a login, the
// day's notes, and the settings page that replaces the Schulcloud token.
{
const root = `http://127.0.0.1:${port}`;
const jsonHeaders = { 'content-type': 'application/json' };
const shell = await fetch(`${root}/app/`);
const shellText = await shell.text();
check(
'the app shell is served with a strict content security policy',
shell.ok &&
/text\/html/.test(shell.headers.get('content-type') ?? '') &&
/default-src 'none'/.test(shell.headers.get('content-security-policy') ?? '') &&
/no-store/.test(shell.headers.get('cache-control') ?? ''),
shell.headers.get('content-security-policy')?.slice(0, 40),
);
check('the shell holds no secret of its own', !shellText.includes(WEB_PASSWORD) && !shellText.includes(TOKEN));
// Every file the shell asks for, including the two modules the editor is
// made of: a missing one leaves a page that loads and cannot type.
const assetNames = ['app.js', 'editor.js', 'markdown.js', 'app.css', 'icon.svg', 'manifest.webmanifest'];
const assets = await Promise.all(assetNames.map((name) => fetch(`${root}/app/${name}`)));
check('the app\'s assets are served', assets.every((response) => response.ok), assets.map((r) => r.status).join(' '));
check(
'the editor\'s modules are served as JavaScript',
assets
.filter((_, index) => assetNames[index].endsWith('.js'))
.every((response) => /javascript/.test(response.headers.get('content-type') ?? '')),
assets.map((r) => r.headers.get('content-type')).join(' | '),
);
check(
'the shell loads the app as a module, so its imports resolve',
/<script type="module" src="app\.js">/.test(shellText) && shellText.includes('data-command="bold"'),
);
const anonymousSession = await (await fetch(`${root}/app/session`)).json();
check('session says "not logged in" rather than failing', anonymousSession.authenticated === false);
const closed = await fetch(`${root}/api/notes`);
check('/api is closed without a session or a token', closed.status === 401, `got ${closed.status}`);
const wrong = await fetch(`${root}/app/login`, {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({ password: 'not-the-password' }),
});
check('a wrong password is refused with no detail', wrong.status === 401, `got ${wrong.status}`);
const login = await fetch(`${root}/app/login`, {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({ password: WEB_PASSWORD }),
});
const setCookie = login.headers.get('set-cookie') ?? '';
check(
'logging in sets an HttpOnly, SameSite=Strict session cookie',
login.ok && /HttpOnly/.test(setCookie) && /SameSite=Strict/i.test(setCookie),
setCookie.split(';').slice(1).join(';').trim(),
);
const cookie = setCookie.split(';')[0] ?? '';
const withSession = { cookie };
const session = await (await fetch(`${root}/app/session`, { headers: withSession })).json();
check('the session is recognised', session.authenticated === true);
const viaSession = await fetch(`${root}/api/notes`, { headers: withSession });
check('a logged-in browser reaches /api without a token', viaSession.ok, `got ${viaSession.status}`);
const mcpViaSession = await fetch(`${root}/mcp`, {
method: 'POST',
headers: { ...jsonHeaders, accept: 'application/json, text/event-stream', ...withSession },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
});
// The session is for the app. Nothing in a browser speaks MCP, and a surface
// that is not needed is not offered.
check('the session does not open /mcp', mcpViaSession.status === 401, `got ${mcpViaSession.status}`);
const tampered = await fetch(`${root}/api/notes`, { headers: { cookie: `${cookie.split('=')[0]}=9999999999999.x.forged` } });
check('a forged session cookie is refused', tampered.status === 401, `got ${tampered.status}`);
// The day editor: read the day, write it, read it back.
const day = await (await fetch(`${root}/api/notes/day?date=2026-09-18`, { headers: withSession })).json();
check(
'the day route answers with a path, a title and the timetable state',
day.path === '2026/2026-09-18.md' && /2026/.test(day.title) && ['ok', 'off', 'unavailable'].includes(day.timetable),
`${day.title} — timetable ${day.timetable}, ${day.lessons?.length ?? 0} lesson(s)`,
);
// A subject no other check uses, so "found by its heading" cannot pass by
// matching the subject note the notes section wrote earlier.
const body = '## 1. Geschichte — 08:0008:45\n\nWeimarer Republik: Ursachen des Scheiterns.\n';
const saved = await fetch(`${root}/api/notes/day`, {
method: 'PUT',
headers: { ...jsonHeaders, ...withSession },
body: JSON.stringify({ date: '2026-09-18', text: body }),
});
const savedBody = await saved.json();
check('the day saves', saved.ok && savedBody.path === '2026/2026-09-18.md', `${saved.status}`);
const conflict = await fetch(`${root}/api/notes/day`, {
method: 'PUT',
headers: { ...jsonHeaders, ...withSession },
body: JSON.stringify({ date: '2026-09-18', text: 'überschrieben', expectedModifiedAt: '2020-01-01T00:00:00.000Z' }),
});
check('a save that would clobber a newer version is refused', conflict.status === 409, `got ${conflict.status}`);
const reread = await (await fetch(`${root}/api/notes/day?date=2026-09-18`, { headers: withSession })).json();
// Trimmed on both sides: a stored note ends with exactly one newline, which
// is the editor's business and not something to assert on.
check('the refused save changed nothing', reread.text.trim() === body.trim(), reread.text.split('\n')[0]);
// The lesson heading the page writes has to be the one the index reads back,
// or a day's notes are filed under no subject at all.
const bySubject = await (await fetch(`${root}/api/notes?subject=Geschichte`, { headers: withSession })).json();
check(
'a day note is found by a subject only its lesson headings know',
bySubject.count === 1 && bySubject.notes[0]?.path === '2026/2026-09-18.md',
`${bySubject.count} note(s)`,
);
const badDate = await fetch(`${root}/api/notes/day?date=2026-02-30`, { headers: withSession });
check('a date that does not exist is refused', badDate.status === 400, `got ${badDate.status}`);
const loggedOut = await fetch(`${root}/app/logout`, { method: 'POST', headers: withSession });
check('logging out clears the cookie', loggedOut.ok && /Max-Age=0/.test(loggedOut.headers.get('set-cookie') ?? ''));
}
console.log('\n== api_get guard rails ==');
@@ -256,9 +845,107 @@ console.log('\n== error handling ==');
const bogus = await call('get_course', { courseId: '000000000000000000000000' });
check('unknown id returns a tool error, not a crash', bogus.isError, bogus.text.split('\n')[0]);
console.log('\n== connector token ==');
// claude.ai sends a request header it stores, so the token in it opens /mcp and
// nothing else: /api can replace the Schulcloud token and stream the mirror.
{
const root = `http://127.0.0.1:${port}`;
const viaHeader = new Client({ name: 'smoke-connector', version: '0' }, { capabilities: {} });
await viaHeader.connect(
new StreamableHTTPClientTransport(new URL(`${root}/mcp`), {
requestInit: { headers: { authorization: `Bearer ${CONNECTOR_TOKEN}` } },
}),
);
const connectorTools = await viaHeader.listTools();
check('the connector token opens /mcp', connectorTools.tools.length === tools.length, `${connectorTools.tools.length} tools`);
await viaHeader.close();
const asApiKey = await fetch(`${root}/mcp`, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'x-api-key': CONNECTOR_TOKEN },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'smoke-api-key', version: '0' } },
}),
});
check('the connector token works as x-api-key too', asApiKey.ok, `got ${asApiKey.status}`);
await asApiKey.body?.cancel();
const onApi = await fetch(`${root}/api/token`, { headers: { authorization: `Bearer ${CONNECTOR_TOKEN}` } });
check('the connector token is refused on /api', onApi.status === 401, `got ${onApi.status}`);
}
console.log('\n== secret path and session token ==');
// claude.ai's connector dialog takes only a URL, so /<secret>/mcp serves MCP
// without a bearer token; and the Schulcloud token can be replaced at runtime.
// Nothing here replaces the live token: the one PUT that succeeds sends the
// token already in use, which the server answers without a swap.
{
const root = `http://127.0.0.1:${port}`;
const wrong = await fetch(`${root}/${'f'.repeat(64)}/mcp`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
});
check('a wrong path secret looks like any unknown path (404)', wrong.status === 404, `got ${wrong.status}`);
const viaSecret = new Client({ name: 'smoke-secret-path', version: '0' }, { capabilities: {} });
await viaSecret.connect(new StreamableHTTPClientTransport(new URL(`${root}/${PATH_SECRET}/mcp`)));
const secretTools = await viaSecret.listTools();
check('the secret path serves MCP without a bearer token', secretTools.tools.length === tools.length, `${secretTools.tools.length} tools`);
await viaSecret.close();
const anonymous = await fetch(`${root}/api/token`);
check('/api/token needs the bearer token', anonymous.status === 401, `got ${anonymous.status}`);
const bearer = { authorization: `Bearer ${TOKEN}` };
const statusResponse = await fetch(`${root}/api/token`, { headers: bearer });
const statusText = await statusResponse.text();
const tokenStatus = JSON.parse(statusText);
check(
'/api/token reports the expiry and never the token',
statusResponse.ok && typeof tokenStatus.expiresAt === 'string' && Number.isInteger(tokenStatus.daysLeft) && !statusText.includes(config.jwt),
`${tokenStatus.daysLeft} day(s) left, from ${tokenStatus.source}`,
);
const jwtInUse = config.jwt;
const malformed = await fetch(`${root}/api/token`, {
method: 'PUT',
headers: { ...bearer, 'content-type': 'application/json' },
body: JSON.stringify({ jwt: 'not-a-token' }),
});
const refusal = await malformed.json();
check(
'a malformed token is refused and the one in use stays',
malformed.status === 422 && refusal.error === 'malformed' && config.jwt === jwtInUse,
`${malformed.status} ${refusal.error}`,
);
const same = await fetch(`${root}/api/token`, {
method: 'PUT',
headers: { ...bearer, 'content-type': 'application/json' },
body: JSON.stringify({ jwt: `jwt=${jwtInUse};` }),
});
const sameResult = await same.json();
check('the token already in use is accepted without a swap', same.ok && sameResult.changed === false, `${same.status}`);
const page = await fetch(`${root}/token`);
const script = await fetch(`${root}/token.js`);
check(
'/token page is served with a strict content security policy',
page.ok && /text\/html/.test(page.headers.get('content-type') ?? '') &&
/default-src 'none'/.test(page.headers.get('content-security-policy') ?? '') &&
script.ok && /javascript/.test(script.headers.get('content-type') ?? ''),
);
}
await client.close();
httpServer.close();
await closeServices(services);
await rm(STATE_DIR, { recursive: true, force: true });
await rm(NOTES_DIR, { recursive: true, force: true });
console.log(`\n${results.length - failures}/${results.length} checks passed`);
process.exit(failures === 0 ? 0 : 1);

View File

@@ -4,9 +4,12 @@ import { mkdir } from 'node:fs/promises';
import { basename, dirname, resolve } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { ApiClient, ApiError } from '../cli/client.ts';
import { ApiClient, ApiError, type TokenInfo } from '../cli/client.ts';
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';
/**
@@ -25,6 +28,32 @@ const USAGE = `schulcloud — browse and mirror your Schulcloud files
schulcloud get <fileId> [--out <path>]
schulcloud sync [--dry-run] [--full] [--prune] [--dir <path>] [--jobs <n>]
schulcloud refresh [--course <id>] [--force]
schulcloud token when the server's Schulcloud token expires
schulcloud token set hand the server a fresh one (paste, or pipe it in)
The file manager ("Dateien") — /my, /courses/<course>, /teams/<team>, /shared:
schulcloud fs ls [path] [--long]
schulcloud fs tree [path] [--depth <n>] [--max-folders <n>]
schulcloud fs find <name> [--path <path>] [--type file|folder] [--long]
schulcloud fs get <path> [--out <path>] [--force] [--jobs <n>]
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.
Options are also read from SCHULCLOUD_SERVER, SCHULCLOUD_TOKEN and
SCHULCLOUD_SYNC_DIR. Config file: ${configPath()}
@@ -55,6 +84,13 @@ async function main(argv: string[]): Promise<number> {
return runSync(flags);
case 'refresh':
return refresh(flags);
case 'fs':
return fileManager(flags);
case 'note':
case 'notes':
return notes(flags);
case 'token':
return token(flags);
default:
process.stderr.write(`Unknown command "${command}".\n\n${USAGE}`);
return 2;
@@ -107,6 +143,8 @@ async function list(flags: Flags): Promise<number> {
const api = new ApiClient(await loadCliConfig());
const manifest = await api.manifest();
let entries = manifest.entries.filter((entry) => entry.status !== 'removed');
// A room id works here too: the manifest's courseId is the container id, and
// since rooms were added that container can be a room.
if (flags.course) entries = entries.filter((entry) => entry.courseId === flags.course);
if (entries.length === 0) {
@@ -149,6 +187,122 @@ async function get(flags: Flags): Promise<number> {
return 0;
}
async function fileManager(flags: Flags): Promise<number> {
const [sub, ...args] = flags._ as string[];
const api = new ApiClient(await loadCliConfig());
const out = (line: string) => process.stdout.write(`${line}\n`);
const long = Boolean(flags.long);
switch (sub) {
case 'ls':
return fsList(api, args[0] ?? '/', long, out);
case 'tree':
return fsTree(api, args[0] ?? '/', Number(flags.depth ?? 3), Number(flags['max-folders'] ?? 200), out);
case 'find': {
if (!args[0]) {
process.stderr.write('fs find needs a name, e.g.: schulcloud fs find Erbrecht --path /courses\n');
return 2;
}
const type = flags.type === 'folder' || flags.type === 'file' ? String(flags.type) : 'any';
return fsFind(api, args[0], String(flags.path ?? '/'), type, Number(flags['max-folders'] ?? 400), long, out);
}
case 'get':
if (!args[0]) {
process.stderr.write('fs get needs a path, e.g.: schulcloud fs get "/courses/<course>/<folder>"\n');
return 2;
}
return fsGet(
api,
args[0],
{ out: flags.out ? String(flags.out) : undefined, force: Boolean(flags.force), jobs: Number(flags.jobs ?? 3) },
out,
);
default:
process.stderr.write(`Unknown fs command "${sub ?? ''}". Use ls, tree, find or get.\n\n${USAGE}`);
return 2;
}
}
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;
@@ -183,7 +337,14 @@ async function refresh(flags: Flags): Promise<number> {
const api = new ApiClient(await loadCliConfig());
const scope = flags.course ? String(flags.course) : undefined;
process.stderr.write(`Asking the server to re-crawl ${scope ? `course ${scope}` : 'everything'}\n`);
const result = (await api.refresh(scope, Boolean(flags.force))) as {
let lastNote = 0;
const result = (await api.refresh(scope, Boolean(flags.force), (seconds) => {
// A note every half minute, so a long first crawl does not look hung.
if (seconds - lastNote >= 30) {
lastNote = seconds;
process.stderr.write(` still crawling… ${seconds}s\n`);
}
})) as {
crawlId: number; courses: number; files: number; mirrored: number; extracted: number;
skipped: number; durationMs: number; joined?: boolean;
};
@@ -196,6 +357,57 @@ async function refresh(flags: Flags): Promise<number> {
return 0;
}
/**
* The monthly chore: log in to Schulcloud in a private window, copy the `jwt`
* cookie, paste it here, close the window. The server checks the token with
* Schulcloud before swapping it in, so a bad paste changes nothing.
*/
async function token(flags: Flags): Promise<number> {
const api = new ApiClient(await loadCliConfig());
const sub = flags._[0];
if (sub === undefined || sub === 'status') {
process.stdout.write(`${describeToken(await api.token())}\n`);
return 0;
}
if (sub !== 'set') {
process.stderr.write(`Unknown token command "${sub}". Use "schulcloud token" or "schulcloud token set".\n`);
return 2;
}
const pasted = process.stdin.isTTY
? await readHidden('Paste the value of the "jwt" cookie (input hidden): ')
: await readPiped();
if (!pasted.trim()) {
process.stderr.write('No token given.\n');
return 2;
}
process.stderr.write('Checking it with Schulcloud…\n');
const result = await api.replaceToken(pasted);
process.stdout.write(`${result.changed ? 'Replaced' : 'Already in use'}: ${describeToken(result)}\n`);
if (result.changed && !result.persisted) {
process.stderr.write('Not saved on the server (STATE_DIR is unset): a restart falls back to TSC_JWT_COOKIE.\n');
}
process.stdout.write('Now close the private window — left open, it logs this token out about two hours after login.\n');
return 0;
}
function describeToken(info: TokenInfo): string {
const expiry = info.expiresAt
? `expires ${info.expiresAt.slice(0, 16).replace('T', ' ')} UTC (${info.daysLeft} day(s) left)`
: 'expiry unknown';
const keepalive = info.keepalive;
const session = !keepalive
? 'keepalive off'
: keepalive.running
? `session alive${keepalive.budgetSeconds === undefined ? '' : `, ${Math.round(keepalive.budgetSeconds / 60)} min budget`}`
: 'session ENDED — Schulcloud rejected the token; run: schulcloud token set';
const source =
info.source === 'environment' ? 'from TSC_JWT_COOKIE' : info.source === 'state file' ? 'saved from an earlier replacement' : info.source;
const warning = info.daysLeft !== undefined && info.daysLeft <= 7 ? '\nRenew it soon: schulcloud token set' : '';
return `${expiry}; ${session}; ${source}${warning}`;
}
function describe(event: SyncEvent, dryRun: boolean): string {
switch (event.type) {
case 'download':

View File

@@ -20,7 +20,25 @@ async function main(): Promise<void> {
console.log(message),
)
: undefined;
services.keepalive = keepalive;
keepalive?.start();
// A replaced token is a new session to hold, and very likely the fix for a
// keepalive that stopped on a 401.
services.session.onReplaced(() => keepalive?.restart());
// The token's 30 days end on a date nothing else announces, and the only fix
// needs a person at a browser — so warn a week ahead, twice a day.
const warnIfExpiring = () => {
const { daysLeft } = services.session.status();
if (daysLeft === undefined || daysLeft > 7) return;
console.log(
daysLeft < 0
? '[schulcloud-mcp] session token: EXPIRED. Replace it with `schulcloud token set` or on the /token page.'
: `[schulcloud-mcp] session token: expires in ${daysLeft} day(s). Replace it with \`schulcloud token set\` or on the /token page.`,
);
};
warnIfExpiring();
setInterval(warnIfExpiring, 12 * 60 * 60_000).unref();
// Periodic re-crawl so the index does not drift. Each run only downloads
// files it has never seen, so a steady state costs a few hundred cheap GETs.
@@ -49,11 +67,18 @@ async function main(): Promise<void> {
}
const server = app.listen(config.port, config.bindHost, () => {
const token = services.session.status();
console.log(
`[schulcloud-mcp] listening on ${config.bindHost}:${config.port} — instance ${config.baseUrl}, ` +
`auth ${config.authToken ? 'enabled' : 'DISABLED'}, ` +
`auth ${config.authToken ? 'enabled' : 'DISABLED'}` +
`${config.connectorToken ? ' (plus connector token)' : ''}${config.mcpPathSecret ? ' (plus secret MCP path)' : ''}, ` +
`token from ${token.source}${token.daysLeft === undefined ? '' : `, ${token.daysLeft} day(s) left`}` +
`${token.persistent ? '' : ' (replacements not saved: STATE_DIR unset)'}, ` +
`keepalive ${keepalive ? `every ${Math.round(config.keepaliveIntervalMs / 60_000)}min` : 'off'}, ` +
`index ${services.store ? (config.crawlIntervalMs > 0 ? `every ${Math.round(config.crawlIntervalMs / 3_600_000)}h` : 'on demand') : 'off'}`,
`index ${services.store ? (config.crawlIntervalMs > 0 ? `every ${Math.round(config.crawlIntervalMs / 3_600_000)}h` : 'on demand') : 'off'}, ` +
// The origin, never the key: this is the line that says whether the
// timetable tools exist at all in this deployment.
`untis ${services.untis ? services.untis.origin : 'off'}`,
);
});

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

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

View File

@@ -26,6 +26,80 @@ export interface Manifest {
entries: ManifestEntry[];
}
/** One entry of a file-manager tree or search, as /api/fs returns it. */
export interface FsEntry {
type: 'directory' | 'file';
path: string;
parentPath: string;
depth: number;
id: string;
name: string;
size?: number;
mimeType?: string | null;
blocked?: boolean;
}
export interface FsListing {
path: string;
kind: 'directory' | 'file';
area?: string | null;
directories?: { id: string; name: string; path: string }[];
files?: { id: string; name: string; path: string; size: number; mimeType?: string; blocked: boolean }[];
file?: { id: string; name: string; size: number; mimeType?: string; blocked: boolean };
}
export interface FsWalk {
path: string;
kind: 'directory' | 'file';
entries?: FsEntry[];
matches?: FsEntry[];
file?: FsListing['file'];
visited?: number;
truncated?: boolean;
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;
daysLeft?: number;
source: string;
persistent: boolean;
keepalive: { running: boolean; budgetSeconds?: number; lastExtendedAt?: string; rejectedAt?: string } | null;
}
export class ApiError extends Error {
readonly status: number;
@@ -65,30 +139,120 @@ export class ApiClient {
return (await (await this.request('/api/status')).json()) as Record<string, unknown>;
}
async token(): Promise<TokenInfo> {
return (await (await this.request('/api/token')).json()) as TokenInfo;
}
/** Hands the server a fresh Schulcloud token; it checks the token before using it. */
async replaceToken(jwt: string): Promise<TokenInfo & { changed: boolean; persisted: boolean }> {
const response = await this.request('/api/token', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jwt }),
});
return (await response.json()) as TokenInfo & { changed: boolean; persisted: boolean };
}
async manifest(since?: string): Promise<Manifest> {
const query = since ? `?since=${encodeURIComponent(since)}` : '';
return (await (await this.request(`/api/manifest${query}`)).json()) as Manifest;
}
async refresh(courseId?: string, force = false): Promise<Record<string, unknown>> {
/**
* Starts a re-crawl and waits for it by polling the server's status.
*
* Not one long request: a crawl that downloads every course file can run for
* many minutes, and fetch gives up after five without response headers —
* which reported "fetch failed" for a crawl that was succeeding.
*/
async refresh(
courseId?: string,
force = false,
onWaiting?: (seconds: number) => void,
): Promise<Record<string, unknown>> {
const response = await this.request('/api/refresh', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ courseId, force }),
body: JSON.stringify({ courseId, force, wait: false }),
});
return (await response.json()) as Record<string, unknown>;
const started = (await response.json()) as { joined?: boolean; startedAt?: string | null };
const began = Date.now();
for (;;) {
await new Promise((resolve) => setTimeout(resolve, 3000));
const status = (await this.status()) as {
indexer?: { running?: boolean; lastResult?: Record<string, unknown>; lastError?: string } | null;
};
const indexer = status.indexer;
if (!indexer) throw new ApiError(503, 'The server has no indexer.');
if (indexer.running) {
onWaiting?.(Math.round((Date.now() - began) / 1000));
continue;
}
if (indexer.lastError) throw new ApiError(502, `The re-crawl failed on the server: ${indexer.lastError}`);
if (!indexer.lastResult) throw new ApiError(502, 'The re-crawl finished without a result.');
return { ...indexer.lastResult, joined: started.joined === true };
}
}
/** Streams one file's bytes. */
async file(fileId: string): Promise<Response> {
return this.request(`/api/files/${encodeURIComponent(fileId)}`);
}
// --- the file manager ------------------------------------------------------
async fsList(path: string): Promise<FsListing> {
return (await (await this.request(`/api/fs/list?${new URLSearchParams({ path })}`)).json()) as FsListing;
}
async fsTree(path: string, depth: number, maxFolders: number): Promise<FsWalk> {
const query = new URLSearchParams({ path, depth: String(depth), maxFolders: String(maxFolders) });
return (await (await this.request(`/api/fs/tree?${query}`)).json()) as FsWalk;
}
async fsFind(name: string, path: string, type: string, maxFolders: number): Promise<FsWalk> {
const query = new URLSearchParams({ name, path, type, maxFolders: String(maxFolders) });
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);
return this.request(`/api/fs/file?${query}`);
}
}
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.';
// The file manager's own errors already say what was not found and what is there.
if ((status === 400 || status === 404 || status === 422) && detail) return detail;
return detail ? `HTTP ${status}: ${detail}` : `HTTP ${status}`;
}

218
src/cli/fs.ts Normal file
View File

@@ -0,0 +1,218 @@
import { createWriteStream } from 'node:fs';
import { mkdir, rename, stat, unlink } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { formatBytes } from '../core/extract.ts';
import { resolveWithin, safeComponent } from '../core/paths.ts';
import type { ApiClient, FsEntry } from './client.ts';
/**
* `schulcloud fs` — the file manager ("Dateien") from the command line.
*
* The same tree the MCP fs_* tools show: /my, /courses/<course>, /teams/<team>,
* /shared. Everything goes through the server's /api/fs routes; nothing here
* talks to Schulcloud.
*/
type Out = (line: string) => void;
export async function fsList(api: ApiClient, path: string, long: boolean, out: Out): Promise<number> {
const listing = await api.fsList(path);
if (listing.kind === 'file' && listing.file) {
out(`${listing.path}`);
out(` ${formatBytes(listing.file.size)} ${listing.file.mimeType ?? 'unknown type'} ${listing.file.id}${listing.file.blocked ? ' [blocked]' : ''}`);
return 0;
}
const directories = listing.directories ?? [];
const files = listing.files ?? [];
out(listing.path);
for (const directory of directories) out(` ${directory.name}/${long ? ` ${directory.id}` : ''}`);
for (const file of files) {
const detail = long ? ` ${formatBytes(file.size).padStart(9)} ${file.id}${file.mimeType ? ` ${file.mimeType}` : ''}` : '';
out(` ${file.name}${detail}${file.blocked ? ' [blocked]' : ''}`);
}
if (directories.length + files.length === 0) out(' (empty)');
out(`${directories.length} folder(s), ${files.length} file(s)`);
return 0;
}
export async function fsTree(api: ApiClient, path: string, depth: number, maxFolders: number, out: Out): Promise<number> {
const walk = await api.fsTree(path, depth, maxFolders);
if (walk.kind === 'file') {
out(`${walk.path} is a file.`);
return 0;
}
const entries = walk.entries ?? [];
const children = groupByParent(entries);
out(walk.path);
let files = 0;
let bytes = 0;
const visit = (parent: string, prefix: string) => {
const kids = children.get(parent) ?? [];
kids.forEach((kid, index) => {
const last = index === kids.length - 1;
const branch = last ? '└── ' : '├── ';
if (kid.type === 'directory') {
out(`${prefix}${branch}${kid.name}/`);
visit(kid.path, `${prefix}${last ? ' ' : '│ '}`);
} else {
files++;
bytes += kid.size ?? 0;
out(`${prefix}${branch}${kid.name} (${formatBytes(kid.size ?? 0)})${kid.blocked ? ' [blocked]' : ''}`);
}
});
};
visit(walk.path, '');
out(`\n${files} file(s), ${formatBytes(bytes)}${walk.visited ?? 0} folder(s) listed`);
if (walk.truncated) out(`Stopped after ${maxFolders} folders; pass --max-folders or start deeper.`);
for (const failure of walk.failures ?? []) out(`could not list ${failure.path}: ${failure.reason}`);
return walk.failures?.length ? 1 : 0;
}
export async function fsFind(
api: ApiClient,
name: string,
path: string,
type: string,
maxFolders: number,
long: boolean,
out: Out,
): Promise<number> {
const result = await api.fsFind(name, path, type, maxFolders);
for (const match of result.matches ?? []) {
const suffix = match.type === 'directory' ? '/' : '';
const detail = long && match.type === 'file' ? ` ${formatBytes(match.size ?? 0)} ${match.id}` : long ? ` ${match.id}` : '';
out(`${match.path}${suffix}${detail}`);
}
const count = result.matches?.length ?? 0;
process.stderr.write(`${count} match(es), ${result.visited ?? 0} folder(s) searched\n`);
if (result.truncated) process.stderr.write(`Stopped after ${maxFolders} folders; there may be more. Narrow --path.\n`);
return count > 0 ? 0 : 1;
}
/**
* Downloads a file, or a whole folder recursively.
*
* A folder lands under `--out` (default: a directory named after it) with the
* file manager's structure. Every component is a name from Schulcloud and so
* untrusted: each goes through `safeComponent`, and the joined path through
* `resolveWithin`, exactly as sync does.
*/
export async function fsGet(
api: ApiClient,
path: string,
options: { out?: string; force: boolean; jobs: number },
out: Out,
): Promise<number> {
const target = await api.fsList(path);
if (target.kind === 'file' && target.file) {
const destination = options.out ? resolve(options.out) : resolve(safeComponent(target.file.name, target.file.id));
if (target.file.blocked) {
process.stderr.write(`${target.path}: blocked by the instance virus scanner; not downloaded.\n`);
return 1;
}
await downloadTo(api, { path: target.path }, destination);
out(destination);
return 0;
}
const rootName = target.path === '/' ? 'Dateien' : (target.path.split('/').pop() ?? 'Dateien');
const root = options.out ? resolve(options.out) : resolve(safeComponent(rootName, 'Dateien'));
process.stderr.write(`Listing ${target.path}\n`);
const walk = await api.fsTree(target.path, 12, 1000);
const entries = walk.entries ?? [];
// Rebuild each file's name segments from its parents rather than splitting
// its path: names may contain "/", which would otherwise invent folders.
const segments = new Map<string, string[]>([[walk.path, []]]);
for (const entry of [...entries].sort((a, b) => a.depth - b.depth)) {
const parent = segments.get(entry.parentPath);
if (parent) segments.set(entry.path, [...parent, entry.name]);
}
const files = entries.filter((entry) => entry.type === 'file');
let downloaded = 0;
let skipped = 0;
let failed = 0;
let bytes = 0;
const queue = [...files];
const worker = async () => {
for (let entry = queue.shift(); entry; entry = queue.shift()) {
const parts = segments.get(entry.path);
if (!parts) continue;
const relative = parts.map((part) => safeComponent(part)).join('/');
const destination = resolveWithin(root, relative);
if (entry.blocked) {
process.stderr.write(` blocked ${relative}\n`);
skipped++;
continue;
}
// Unchanged by size: re-running a folder download resumes rather than repeats.
const existing = await stat(destination).catch(() => undefined);
if (!options.force && existing?.isFile() && existing.size === entry.size) {
skipped++;
continue;
}
try {
await downloadTo(api, { id: entry.id, name: entry.name }, destination);
downloaded++;
bytes += entry.size ?? 0;
process.stderr.write(` get ${relative}\n`);
} catch (error) {
failed++;
process.stderr.write(` FAILED ${relative}: ${(error as Error).message}\n`);
}
}
};
await Promise.all(Array.from({ length: Math.max(1, options.jobs) }, worker));
out(root);
process.stderr.write(
`Downloaded ${downloaded} file(s) (${formatBytes(bytes)}), ${skipped} skipped` +
`${failed ? `, FAILED ${failed}` : ''}${walk.visited ?? 0} folder(s) listed\n`,
);
if (walk.truncated) process.stderr.write('The folder is larger than one listing pass; some files were not reached.\n');
for (const failure of walk.failures ?? []) process.stderr.write(`could not list ${failure.path}: ${failure.reason}\n`);
return failed > 0 || (walk.failures?.length ?? 0) > 0 ? 1 : 0;
}
async function downloadTo(api: ApiClient, target: { path: string } | { id: string; name: string }, destination: string) {
const response = await api.fsFile(target);
if (!response.body) throw new Error('empty response body');
await mkdir(dirname(destination), { recursive: true });
// A temporary neighbour, renamed into place, so an interrupted download never
// leaves a half-file that the size check would later accept as complete.
const temp = `${destination}.part`;
try {
await pipeline(Readable.fromWeb(response.body as never), createWriteStream(temp));
await rename(temp, destination);
} catch (error) {
await unlink(temp).catch(() => {});
throw error;
}
}
function groupByParent(entries: FsEntry[]): Map<string, FsEntry[]> {
const children = new Map<string, FsEntry[]>();
for (const entry of entries) {
const list = children.get(entry.parentPath) ?? [];
list.push(entry);
children.set(entry.parentPath, list);
}
for (const list of children.values()) {
list.sort((a, b) =>
a.type !== b.type
? a.type === 'directory'
? -1
: 1
: a.name.localeCompare(b.name, 'de', { numeric: true, sensitivity: 'base' }),
);
}
return children;
}

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

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

53
src/cli/prompt.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* Reading a secret the user pastes.
*
* The Schulcloud token grants read access to the whole account, so it is never
* a command-line argument (shell history) and never echoed (scrollback).
*/
/** A line typed or pasted at the terminal, not echoed. */
export function readHidden(prompt: string): Promise<string> {
const input = process.stdin;
return new Promise((resolve, reject) => {
let value = '';
const finish = () => {
input.off('data', onData);
input.setRawMode(false);
input.pause();
process.stderr.write('\n');
};
const onData = (chunk: string) => {
for (const char of chunk) {
if (char === '\r' || char === '\n') {
finish();
// A terminal with bracketed paste on wraps a paste in markers.
resolve(value.replace(/\[20[01]~/g, ''));
return;
}
if (char === '' || char === '') {
finish();
reject(new Error('Cancelled.'));
return;
}
if (char === '' || char === '\b') {
value = value.slice(0, -1);
continue;
}
value += char;
}
};
process.stderr.write(prompt);
input.setRawMode(true);
input.setEncoding('utf8');
input.resume();
input.on('data', onData);
});
}
/** Everything piped in, for `wl-paste | schulcloud token set` and the like. */
export async function readPiped(): Promise<string> {
let data = '';
process.stdin.setEncoding('utf8');
for await (const chunk of process.stdin) data += chunk;
return data;
}

View File

@@ -1,18 +1,41 @@
/**
* Runtime configuration, read once from the environment.
* Runtime configuration, read once from the environment — except `jwt`, which
* can be replaced while the server runs.
*
* The two Schulcloud values are named after the browser artefacts they come
* from (`TSC_URL`, `TSC_JWT_COOKIE`) so that copying a fresh token out of
* DevTools stays an obvious, mechanical step — see docs/AUTH.md.
*/
import { resolve } from 'node:path';
import type { UntisConfig } from './core/untis.ts';
export interface Config {
/** Instance base URL, no trailing slash, e.g. `https://schulcloud-thueringen.de`. */
baseUrl: string;
/** Raw JWT from the instance's `jwt` cookie. Sent as `Authorization: Bearer`. */
/**
* Raw JWT from the instance's `jwt` cookie. Sent as `Authorization: Bearer`.
* Replaced at runtime by core/session-token.ts, so read it at the moment of
* use and never keep a copy.
*/
jwt: string;
/** Shared secret callers must present to this MCP server. Unused in stdio mode. */
authToken: string | undefined;
/**
* A second token, accepted on `/mcp` only: the one claude.ai's connector
* sends as a request header. It is stored by a third party, so it opens the
* read-only MCP tools and not `/api`, which can replace the session token
* and stream the file mirror — and it can be revoked on its own.
*/
connectorToken: string | undefined;
/**
* Serves MCP at `/<secret>/mcp` without a bearer token, for clients that can
* send none — claude.ai's connector dialog takes only a URL. The path is then
* the credential, so it must never be logged.
*/
mcpPathSecret: string | undefined;
/** Where state that must survive a restart is kept: a replaced session token. Unset = memory only. */
stateDir: string | undefined;
port: number;
bindHost: string;
/** Hard ceiling on how many bytes `download_file` will pull from the instance. */
@@ -33,8 +56,47 @@ export interface Config {
mirrorDir: string;
/** Files larger than this are indexed as metadata but not mirrored. */
mirrorMaxBytes: number;
/** Index personal files and submitted/returned work as well as course content. */
indexPersonalFiles: boolean;
/** Walk the file manager (Kurs-, Persönliche, Team- and Geteilte Dateien) when crawling. */
indexFileManager: boolean;
/** How often to re-crawl on a timer. Zero = only on demand. */
crawlIntervalMs: number;
/**
* Password for the web app at `/app` — the notes editor and the settings
* page. Unset = the app is not served at all, by the same rule the untis_*
* tools follow: a login screen no password can open is worse than no page.
*
* A credential, and the only one here a person types: it is hashed at
* startup and the plain value is never compared, stored or logged.
*/
webPassword: string | undefined;
/**
* Where the user's own lesson notes live, as Markdown files. Unset = the
* note tools are not offered, the same rule the untis_* tools follow.
*/
notesDir: string | undefined;
/**
* Whether add_note may write. The notes directory is the only thing in this
* server anything can write to, so turning it off is a real setting and not
* a theoretical one — a deployment that syncs its notes in from elsewhere
* wants the files left alone.
*/
notesWritable: boolean;
/**
* How far back to read the WebUntis class register into the index. Zero =
* not at all. Costs one timetable call per 90 days plus one per lesson
* series, so a school year is a few dozen requests on a background crawl.
*/
untisHistoryDays: number;
/**
* WebUntis, where the school keeps the timetable. Unset = the untis_* tools
* are not offered at all, which is the right answer for a school that does
* not use it — Schulcloud alone cannot say what happens when.
*/
untis: UntisConfig | undefined;
}
function required(name: string): string {
@@ -43,6 +105,13 @@ function required(name: string): string {
return value;
}
/** `1`, `true`, `yes` and `on` are all true; anything else falls back. */
function bool(name: string, fallback: boolean): boolean {
const raw = process.env[name]?.trim().toLowerCase();
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw);
}
function int(name: string, fallback: number): number {
const raw = process.env[name]?.trim();
if (!raw) return fallback;
@@ -53,6 +122,94 @@ function int(name: string, fallback: number): number {
return parsed;
}
/** A URL-safe secret of at least 32 characters, or undefined when unset. */
function pathSecret(name: string): string | undefined {
const value = process.env[name]?.trim();
if (!value) return undefined;
// The value is a credential: the error states the rule and never echoes it.
if (!/^[A-Za-z0-9_-]{32,}$/.test(value)) {
throw new Error(
`Environment variable ${name} must be at least 32 characters of A-Z, a-z, 0-9, "-" or "_". ` +
'Generate one with: openssl rand -hex 32',
);
}
return value;
}
/** A token of at least 32 characters without whitespace, or undefined when unset. */
function secretToken(name: string): string | undefined {
const value = process.env[name]?.trim();
if (!value) return undefined;
// A credential: the error states the rule and never echoes the value.
if (value.length < 32 || /\s/.test(value)) {
throw new Error(
`Environment variable ${name} must be at least 32 characters without spaces. ` +
'Generate one with: openssl rand -hex 32',
);
}
return value;
}
/**
* The four WebUntis values, or undefined when none is set.
*
* All four or nothing: three of them are harmless identifiers and the fourth is
* a credential, so a half-filled block is a copy-paste that went wrong, not a
* configuration to guess at. They come from one dialog — WebUntis → Profil →
* Freigaben → Untis Mobile → QR-Code — and the error says so, because that is
* the only place to find them.
*/
function untisConfig(): UntisConfig | undefined {
const server = process.env.UNTIS_SERVER?.trim();
const school = process.env.UNTIS_SCHOOL?.trim();
const user = process.env.UNTIS_USER?.trim();
const secret = process.env.UNTIS_SECRET?.trim();
const missing = Object.entries({ UNTIS_SERVER: server, UNTIS_SCHOOL: school, UNTIS_USER: user, UNTIS_SECRET: secret })
.filter(([, value]) => !value)
.map(([name]) => name);
if (missing.length === 4) return undefined;
if (!server || !school || !user || !secret) {
throw new Error(
`WebUntis needs all four of UNTIS_SERVER, UNTIS_SCHOOL, UNTIS_USER and UNTIS_SECRET — missing: ` +
`${missing.join(', ')}. All four are in WebUntis → Profil → Freigaben → Untis Mobile → QR-Code.`,
);
}
if (!/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(server)) {
throw new Error(
`UNTIS_SERVER must be the bare host from the QR dialog's "Url" field, e.g. "ags-erfurt.webuntis.com" — ` +
`no scheme and no path, got "${server}".`,
);
}
// A credential: the error states the rule and never echoes the value.
if (!/^[A-Za-z2-7]{8,}$/.test(secret)) {
throw new Error(
'UNTIS_SECRET must be the key from the Untis Mobile QR dialog: at least 8 characters of A-Z and 2-7 ' +
'(base32), no spaces.',
);
}
return { server, school, user, secret };
}
/**
* The app password, or undefined when the app is switched off.
*
* A length floor and nothing else: this one is typed by a person on a phone,
* so demanding punctuation would buy little and cost the thing that actually
* matters, which is that they pick something long. The error states the rule
* and never echoes the value.
*/
function webPassword(): string | undefined {
const value = process.env.WEB_PASSWORD;
if (!value) return undefined;
if (value.length < 12) {
throw new Error(
'WEB_PASSWORD must be at least 12 characters — it is the only thing between the internet and the ' +
'notes app. A passphrase of three or four words is ideal.',
);
}
return value;
}
/** Like `int`, but 0 is meaningful (it disables the feature) rather than invalid. */
function intAllowingZero(name: string, fallback: number): number {
const raw = process.env[name]?.trim();
@@ -65,10 +222,23 @@ function intAllowingZero(name: string, fallback: number): number {
}
export function loadConfig(): Config {
const authToken = process.env.MCP_AUTH_TOKEN?.trim() || undefined;
const connectorToken = secretToken('MCP_CONNECTOR_TOKEN');
if (connectorToken && !authToken) {
// Without the main token /api would be open while /mcp is not.
throw new Error('MCP_CONNECTOR_TOKEN needs MCP_AUTH_TOKEN as well, or /api would be left unauthenticated.');
}
if (connectorToken && connectorToken === authToken) {
throw new Error('MCP_CONNECTOR_TOKEN must differ from MCP_AUTH_TOKEN, or it cannot be limited to /mcp or revoked on its own.');
}
return {
baseUrl: required('TSC_URL').replace(/\/+$/, ''),
jwt: required('TSC_JWT_COOKIE'),
authToken: process.env.MCP_AUTH_TOKEN?.trim() || undefined,
authToken,
connectorToken,
mcpPathSecret: pathSecret('MCP_PATH_SECRET'),
stateDir: process.env.STATE_DIR?.trim() ? resolve(process.env.STATE_DIR.trim()) : undefined,
port: int('PORT', 8080),
bindHost: process.env.BIND_HOST?.trim() || '0.0.0.0',
maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024),
@@ -76,8 +246,24 @@ export function loadConfig(): Config {
requestTimeoutMs: int('REQUEST_TIMEOUT_MS', 30_000),
keepaliveIntervalMs: intAllowingZero('KEEPALIVE_INTERVAL_MS', 30 * 60_000),
databaseUrl: process.env.DATABASE_URL?.trim() || undefined,
mirrorDir: process.env.MIRROR_DIR?.trim() || '/var/lib/schulcloud-mcp/mirror',
// Absolute: res.sendFile rejects a relative path, and resolveWithin only
// returns an absolute path if the root it is given is one.
mirrorDir: resolve(process.env.MIRROR_DIR?.trim() || '/var/lib/schulcloud-mcp/mirror'),
mirrorMaxBytes: int('MIRROR_MAX_BYTES', 64 * 1024 * 1024),
// Off by default: submissions are per task, so this roughly doubles the
// cost of a full crawl. Worth turning on to make your own handed-in work
// searchable, which no other route offers.
indexPersonalFiles: bool('INDEX_PERSONAL_FILES', false),
// On by default: many teachers keep their material only in Kurs-Dateien,
// so an index without it misses whole courses. One page load per folder.
indexFileManager: bool('INDEX_FILE_MANAGER', true),
crawlIntervalMs: intAllowingZero('CRAWL_INTERVAL_MS', 6 * 60 * 60_000),
// Absolute for the same reason as the mirror: resolveWithin only returns
// an absolute path when the root it is given is one.
webPassword: webPassword(),
notesDir: process.env.NOTES_DIR?.trim() ? resolve(process.env.NOTES_DIR.trim()) : undefined,
notesWritable: !bool('NOTES_READONLY', false),
untisHistoryDays: intAllowingZero('UNTIS_HISTORY_DAYS', 180),
untis: untisConfig(),
};
}

View File

@@ -1,6 +1,8 @@
import type { Config } from './config.ts';
import { SchulcloudClient } from './core/client.ts';
import type { MeResponse } from './core/types.ts';
import { FileManager } from './core/legacy-files.ts';
import type { LegacyUser, MeResponse } from './core/types.ts';
import { UntisClient } from './core/untis.ts';
import type { Indexer } from './indexer/indexer.ts';
import type { Store } from './store/store.ts';
@@ -14,16 +16,42 @@ import type { Store } from './store/store.ts';
export class ServerContext {
readonly config: Config;
readonly client: SchulcloudClient;
/** The "Dateien" file manager; shared across sessions when the process provides one. */
readonly files: FileManager;
/** Shared across sessions; undefined when running without an index. */
readonly store: Store | undefined;
readonly indexer: Indexer | undefined;
/** WebUntis — the timetable — or undefined when the server has no key for it. */
readonly untis: UntisClient | undefined;
private identity: Promise<MeResponse> | undefined;
/**
* id -> display name, for the whole session.
*
* Submission `submitters`, file `creatorId` and course `teacherIds` are all
* bare ids, and the only route that resolves one is `/api/v1/users/{id}` —
* one request per person. Names do not change within a session and the same
* handful of people recur across every course, so this is cached hard,
* including the misses: a lookup a student is not allowed to make would
* otherwise be retried for every row it appears in.
*/
private readonly userNames = new Map<string, Promise<string | undefined>>();
constructor(config: Config, shared?: { client?: SchulcloudClient; store?: Store; indexer?: Indexer }) {
constructor(
config: Config,
shared?: {
client?: SchulcloudClient;
files?: FileManager;
store?: Store;
indexer?: Indexer;
untis?: UntisClient;
},
) {
this.config = config;
this.client = shared?.client ?? new SchulcloudClient(config);
this.files = shared?.files ?? new FileManager(this.client);
this.store = shared?.store;
this.indexer = shared?.indexer;
this.untis = shared?.untis ?? (config.untis ? new UntisClient(config.untis, config.requestTimeoutMs) : undefined);
}
/** Cached `/me`. Shared promise, so concurrent first calls make one request. */
@@ -40,8 +68,56 @@ export class ServerContext {
return (await this.me()).school.id;
}
/**
* Display name for a user id, or undefined when it cannot be resolved.
*
* Never throws: a 403 here is ordinary — a student may read their own
* classmates but not every id that appears on a board — and a row that
* falls back to the bare id is far better than a tool that fails.
*/
userName(userId: string): Promise<string | undefined> {
let pending = this.userNames.get(userId);
if (!pending) {
pending = this.client
.getLegacyUser(userId)
.then((user: LegacyUser) => {
const name = user.fullName ?? user.displayName ?? [user.firstName, user.lastName].filter(Boolean).join(' ');
return name.trim() || undefined;
})
.catch(() => undefined);
this.userNames.set(userId, pending);
}
return pending;
}
/** Resolves several ids at once, falling back to the id itself. */
async userNamesFor(userIds: string[]): Promise<string[]> {
const unique = [...new Set(userIds)];
const names = await Promise.all(unique.map(async (id) => (await this.userName(id)) ?? id));
return names;
}
/**
* Resolves several ids, reporting how many could not be read.
*
* `/api/v1/users/{id}` answers 403 for anyone but yourself unless the account
* has permission over them: a teacher can read their students, a student
* cannot read their teachers. Printing the raw id in that case is noise, so
* callers that would show a name to a human use this and say "2 others"
* instead of pasting two 24-character ids.
*/
async resolveNames(userIds: string[]): Promise<{ names: string[]; unresolved: number }> {
const unique = [...new Set(userIds)];
const resolved = await Promise.all(unique.map((id) => this.userName(id)));
return {
names: resolved.filter((name): name is string => Boolean(name)),
unresolved: resolved.filter((name) => !name).length,
};
}
/** Drops the cached identity so the next call re-reads it. */
reset(): void {
this.identity = undefined;
this.userNames.clear();
}
}

View File

@@ -1,6 +1,7 @@
import type { Config } from '../config.ts';
import type { SchulcloudClient } from './client.ts';
import { fetchPadText } from './etherpad.ts';
import { readH5pContent, type H5pContent } from './h5p.ts';
import { SchulcloudApiError } from './client.ts';
import type { BoardSkeleton, CardResponse, ContentElement, FileRecord } from './types.ts';
@@ -26,6 +27,18 @@ export interface AssembledElement {
fileError?: string;
/** What a class actually wrote in a collaborativeTextEditor (Etherpad) pad. */
padText?: string;
/** Longer body text: a link's description, a drawing's, a deleted element's. */
description?: string;
/** Image alt text — often the only description a picture carries. */
alternativeText?: string;
/** H5P content id: the handle onto interactive content (quizzes and the like). */
h5pContentId?: string;
/** The exercise behind that id, when it was resolved: every question at once. */
h5p?: H5pContent;
/** Which configured tool an externalTool element launches. */
contextExternalToolId?: string;
/** What a deleted element used to be. */
deletedElementType?: string;
raw: Record<string, unknown>;
}
@@ -56,7 +69,7 @@ export async function assembleBoard(
client: SchulcloudClient,
boardId: string,
schoolId: string,
options: { resolveFiles?: boolean; resolvePads?: Config } = {},
options: { resolveFiles?: boolean; resolvePads?: Config; resolveH5p?: boolean } = {},
): Promise<AssembledBoard> {
const resolveFiles = options.resolveFiles ?? true;
@@ -82,6 +95,12 @@ export async function assembleBoard(
await attachPadText(options.resolvePads, assembled);
}
// A quiz is course material too, and the board hands over only a contentId.
// One request per H5P element, and only when a board has one.
if (options.resolveH5p) {
await attachH5pContent(client, assembled);
}
const fileCount = assembled
.flatMap((column) => column.cards)
.flatMap((card) => card.elements)
@@ -120,14 +139,46 @@ function buildElement(element: ContentElement): AssembledElement {
if (element.type === 'link') {
if (typeof content.url === 'string') assembled.url = content.url;
if (typeof content.title === 'string') assembled.text = content.title;
// A link's description is where the teacher says why it is worth opening.
if (typeof content.description === 'string') assembled.description = content.description;
}
if ((element.type === 'file' || element.type === 'fileFolder') && typeof content.caption === 'string') {
const caption = content.caption.trim();
if (caption) assembled.text = caption;
}
// For an image this is frequently the only text describing what it shows,
// and it is the one field a screen-reader user is guaranteed to get.
if (element.type === 'file' && typeof content.alternativeText === 'string') {
assembled.alternativeText = content.alternativeText;
}
if (element.type === 'fileFolder' && typeof content.title === 'string') {
const title = content.title.trim();
if (title) assembled.text = title;
}
if (element.type === 'drawing' && typeof content.description === 'string') {
assembled.description = content.description;
}
if (element.type === 'collaborativeTextEditor' || element.type === 'externalTool') {
if (typeof content.title === 'string') assembled.text = content.title;
}
// videoConference carries a title too; dropping it left the element rendered
// as a bare id with no hint of which meeting it is.
if (element.type === 'videoConference' && typeof content.title === 'string') {
assembled.text = content.title;
}
if (element.type === 'externalTool' && typeof content.contextExternalToolId === 'string') {
assembled.contextExternalToolId = content.contextExternalToolId;
}
// The only handle onto H5P content — quizzes and other interactive material
// reach the board this way, and without the id there is nothing to follow.
if (element.type === 'h5p' && typeof content.contentId === 'string') {
assembled.h5pContentId = content.contentId;
}
if (element.type === 'deleted') {
if (typeof content.title === 'string') assembled.text = content.title;
if (typeof content.description === 'string') assembled.description = content.description;
if (typeof content.deletedElementType === 'string') assembled.deletedElementType = content.deletedElementType;
}
return assembled;
}
@@ -155,6 +206,29 @@ async function attachPadText(config: Config, columns: AssembledColumn[]): Promis
);
}
/**
* Fills in the exercise behind each H5P element.
*
* Silent on failure, like a pad: the element and its content id are still
* reported, and get_h5p then says why it could not be read.
*/
async function attachH5pContent(client: SchulcloudClient, columns: AssembledColumn[]): Promise<void> {
const elements = columns
.flatMap((column) => column.cards)
.flatMap((card) => card.elements)
.filter((element) => element.type === 'h5p' && element.h5pContentId);
await Promise.all(
elements.map(async (element) => {
try {
element.h5p = await readH5pContent(client, element.h5pContentId!);
} catch {
// Left unset: the formatter falls back to naming the content id.
}
}),
);
}
/**
* Resolves file-bearing elements to file records.
*

View File

@@ -3,17 +3,29 @@ import type {
BoardContext,
BoardSkeleton,
CardResponse,
ClassItem,
CourseBoardResponse,
CourseMetadata,
DashboardResponse,
FileParentType,
FileRecord,
GroupItem,
LegacyCourse,
LegacyUser,
LessonResponse,
MeResponse,
NewsResponse,
Paginated,
ParentFileStats,
PreviewWidth,
SubmissionStatus,
LessonLinkedTask,
RoomApplicant,
RoomBoardItem,
RoomDetails,
RoomInvitationLink,
RoomItem,
RoomMember,
TaskContent,
} from './types.ts';
@@ -23,6 +35,9 @@ import type {
*/
export const MAX_IDS_PER_QUERY = 20;
/** The only output format the preview endpoint accepts; anything else is a 400. */
const PREVIEW_OUTPUT_FORMAT = 'image/webp';
/** Statuses worth retrying: transient by definition, and every call here is a GET. */
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
const MAX_RETRIES = 3;
@@ -104,20 +119,43 @@ export class SchulcloudClient {
return url;
}
private async request(url: URL, accept: string): Promise<Response> {
/**
* One upstream GET, with retries for the transient failures.
*
* `auth` picks how the session travels. The v3 API takes it as a bearer
* token; the legacy client's pages take it only as the `jwt` cookie; and a
* pre-signed storage URL must get **nothing** — it lives on another host, and
* the session token has no business leaving this instance. Anything but the
* bearer form is fetched with redirects off, so a login bounce or a hop to a
* third host is seen rather than silently followed with credentials attached.
*/
private async request(
url: URL,
accept: string,
auth: 'bearer' | 'cookie' | 'none' = 'bearer',
options: { idleTimeout?: boolean; token?: string } = {},
): Promise<Response> {
let lastError: unknown;
const headers: Record<string, string> = { Accept: accept };
// Read at the moment of use, never earlier: the token can be replaced
// while the server runs (core/session-token.ts).
const jwt = options.token ?? this.config.jwt;
if (auth === 'bearer') headers.Authorization = `Bearer ${jwt}`;
if (auth === 'cookie') headers.Cookie = `jwt=${jwt}`;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
if (attempt > 0) await delay(backoffMs(attempt));
let response: Response;
const deadline = options.idleTimeout ? idleDeadline(this.config.requestTimeoutMs) : undefined;
try {
response = await fetch(url, {
headers: { Authorization: `Bearer ${this.config.jwt}`, Accept: accept },
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
redirect: 'follow',
headers,
signal: deadline?.signal ?? AbortSignal.timeout(this.config.requestTimeoutMs),
redirect: auth === 'bearer' ? 'follow' : 'manual',
});
} catch (error) {
deadline?.stop();
// Connection reset or timeout: worth one more try, since every call
// here is an idempotent GET.
lastError = error;
@@ -125,10 +163,16 @@ export class SchulcloudClient {
continue;
}
if (response.ok) return response;
if (response.ok) return deadline ? deadline.watch(response) : response;
deadline?.stop();
const body = await response.text().catch(() => '');
const error = new SchulcloudApiError(response.status, url.pathname + url.search, body);
// A pre-signed URL's query string is its credential, so it never goes
// into an error message; nor does the storage host's error body.
const error =
auth === 'none'
? new SchulcloudApiError(response.status, `${url.host} (pre-signed download)`, '')
: new SchulcloudApiError(response.status, url.pathname + url.search, body);
// A crawl issues hundreds of requests and the instance answers some of
// them with a 503 front-page when it decides we are going too fast.
@@ -159,7 +203,12 @@ export class SchulcloudClient {
*/
async getBytes(path: string, fallbackName: string): Promise<DownloadedFile> {
const url = this.url(path);
const response = await this.request(url, '*/*');
const response = await this.request(url, '*/*', 'bearer', { idleTimeout: true });
return this.readCapped(response, fallbackName);
}
/** Reads a response body up to `maxDownloadBytes`, flagging anything cut off. */
private async readCapped(response: Response, fallbackName: string): Promise<DownloadedFile> {
const limit = this.config.maxDownloadBytes;
const chunks: Buffer[] = [];
@@ -207,6 +256,15 @@ export class SchulcloudClient {
return this.getJson<MeResponse>('/api/v3/me');
}
/**
* `/me` as another token sees it, leaving the token in use untouched — how a
* replacement is checked before it is swapped in.
*/
async meAs(token: string): Promise<MeResponse> {
const response = await this.request(this.url('/api/v3/me'), 'application/json', 'bearer', { token });
return (await response.json()) as MeResponse;
}
// --- session ---------------------------------------------------------
/**
@@ -323,6 +381,65 @@ export class SchulcloudClient {
return Array.isArray(body) ? body : (body.data ?? []);
}
// --- rooms ------------------------------------------------------------
/**
* The rooms this account belongs to.
*
* Returns `{data}` with no `total` — not the usual paginated envelope. The
* server derives the list from actual room memberships, so an empty result
* means exactly that, and a room a teacher revoked access to simply stops
* appearing.
*/
async listRooms(): Promise<RoomItem[]> {
const body = await this.getJson<{ data?: RoomItem[] }>('/api/v3/rooms');
return body.data ?? [];
}
getRoom(roomId: string): Promise<RoomDetails> {
return this.getJson<RoomDetails>(`/api/v3/rooms/${encodeURIComponent(roomId)}`);
}
async listRoomBoards(roomId: string): Promise<RoomBoardItem[]> {
const body = await this.getJson<Paginated<RoomBoardItem>>(
`/api/v3/rooms/${encodeURIComponent(roomId)}/boards`,
);
return body.data ?? [];
}
/**
* A room's members. Needs no special permission for a member to see who else
* is in the room, but it can still be refused — callers treat that as "not
* available" rather than as an error worth surfacing.
*/
async listRoomMembers(roomId: string): Promise<RoomMember[]> {
const body = await this.getJson<{ data?: RoomMember[] }>(
`/api/v3/rooms/${encodeURIComponent(roomId)}/members`,
);
return body.data ?? [];
}
/**
* People waiting to be let into a room, and the room's invitation links.
*
* Both are room-admin surface: a viewer gets 403, which is ordinary rather
* than exceptional. `allowedOperations` on the room says which of these the
* account may ask for, so callers can skip the ones that would be refused.
*/
async listRoomApplicants(roomId: string): Promise<RoomApplicant[]> {
const body = await this.getJson<{ data?: RoomApplicant[] }>(
`/api/v3/rooms/${encodeURIComponent(roomId)}/applicants`,
);
return body.data ?? [];
}
async listRoomInvitationLinks(roomId: string): Promise<RoomInvitationLink[]> {
const body = await this.getJson<{ data?: RoomInvitationLink[] }>(
`/api/v3/rooms/${encodeURIComponent(roomId)}/room-invitation-links`,
);
return body.data ?? [];
}
// --- column boards ---------------------------------------------------
getBoardSkeleton(boardId: string): Promise<BoardSkeleton> {
@@ -333,6 +450,21 @@ export class SchulcloudClient {
return this.getJson<BoardContext>(`/api/v3/boards/${encodeURIComponent(boardId)}/context`);
}
/**
* The content behind an H5P element: a quiz with all of its questions.
*
* `params` is what the player loads before it renders anything, so one GET
* returns the whole exercise — every question, every answer option and which
* of them is correct — even though the player then shows one question at a
* time. There is nothing to step through and no page to scrape.
*
* The shape belongs to whichever H5P library the content uses, so it stays
* `unknown` here and `core/h5p.ts` interprets it.
*/
getH5pParams(contentId: string): Promise<unknown> {
return this.getJson<unknown>(`/api/v3/h5p-editor/params/${encodeURIComponent(contentId)}`);
}
/**
* Card bodies for the given ids.
*
@@ -399,6 +531,239 @@ export class SchulcloudClient {
limit: clampPageSize(params.limit),
});
}
/** File count and total bytes under one parent, without listing the records. */
getParentFileStats(parentType: FileParentType, parentId: string): Promise<ParentFileStats> {
return this.getJson<ParentFileStats>(
`/api/v3/file/stats/${parentType}/${encodeURIComponent(parentId)}`,
);
}
/**
* A rasterised preview of one file.
*
* The reason this exists: many course PDFs are image-only scans, so text
* extraction legitimately yields nothing and their contents are otherwise
* unreadable. A preview is a picture of the page, which Claude can read
* directly. Only meaningful when the record's `previewStatus` is
* `preview_possible`; anything else 404s or returns the placeholder.
*/
async getFilePreview(
record: Pick<FileRecord, 'id' | 'name'>,
width?: PreviewWidth,
): Promise<DownloadedFile> {
// Two traps here, both of which answer with a 400 that names the value but
// not the permitted set:
// - `width` is an enum (50 | 150 | 500), not a free number;
// - `outputFormat` accepts only `image/webp`. Omitting it is worse than
// wrong: the preview is then rendered in the *source* format, so a PDF
// comes back as a PDF and the whole point — a picture of the page — is
// lost.
const query = new URLSearchParams({ outputFormat: PREVIEW_OUTPUT_FORMAT });
if (width) query.set('width', String(width));
const path =
`/api/v3/file/preview/${encodeURIComponent(record.id)}/${encodeURIComponent(record.name)}` +
`?${query.toString()}`;
const file = await this.getBytes(path, record.name);
// The response labels itself `webp` rather than `image/webp`, which no
// image consumer would accept. We asked for the format, so we know it.
return file.mimeType.startsWith('image/') ? file : { ...file, mimeType: PREVIEW_OUTPUT_FORMAT };
}
// --- legacy /api/v1 ---------------------------------------------------
//
// Exactly three legacy routes survive in the deployment's ingress table
// (dof_app_deploy .../all/x_ingress.yml): courses, users and classes. They
// are production surface, not a leftover — the table even notes why each
// one is still needed. Everything else under /api/v1 is unrouted and 404s,
// so do not reach for it.
/** One course with the fields v3 drops: description, members, timetable. */
getLegacyCourse(courseId: string): Promise<LegacyCourse> {
return this.getJson<LegacyCourse>(`/api/v1/courses/${encodeURIComponent(courseId)}`);
}
/**
* One user's name.
*
* The only id-to-name mapping available: submission `submitters`, file
* `creatorId` and course `teacherIds` are all bare ids, and no v3 route
* resolves them for a non-admin.
*/
getLegacyUser(userId: string): Promise<LegacyUser> {
return this.getJson<LegacyUser>(`/api/v1/users/${encodeURIComponent(userId)}`);
}
// --- groups and classes ------------------------------------------------
/** Classes ("Klassen") this account belongs to, with teacher names. */
async listClasses(): Promise<ClassItem[]> {
const body = await this.getJson<Paginated<ClassItem>>('/api/v3/groups/class', { limit: MAX_PAGE_SIZE });
return body.data ?? [];
}
/** Groups this account belongs to — room membership groups, classes, courses. */
async listGroups(): Promise<GroupItem[]> {
const body = await this.getJson<Paginated<GroupItem>>('/api/v3/groups', { limit: MAX_PAGE_SIZE });
return body.data ?? [];
}
// --- the "Dateien" file manager ------------------------------------------
//
// Persönliche Dateien, Kurs-Dateien, Team-Dateien and Geteilte Dateien live in
// the legacy file system, a different store from files-storage: listing a
// course through /api/v3/file answers 0 files for a course holding dozens.
// Its Feathers service is not in the public ingress, so the only way in is
// the legacy client — HTML pages for listings, one JSON route for downloads.
// See core/legacy-files.ts for the parsing and the path model.
/**
* One file-manager page, as HTML.
*
* **Only the listing routes are reachable here, by construction.** Several of
* the legacy client's GET routes write: `GET /files/share/` mints a share
* token when the file has none, and `GET /files/file?share=…` grants the
* caller a permission on someone else's file. A GET-only client is therefore
* not read-only against this surface by itself; the allowlist is what makes
* the invariant hold.
*/
async getFileManagerPage(path: string): Promise<string> {
if (!FILE_MANAGER_PAGE.test(path)) {
throw new Error(`refusing file-manager path outside the listing routes: ${path}`);
}
const response = await this.legacyRequest(path, 'text/html');
return response.text();
}
/**
* A pre-signed download URL for one legacy file.
*
* `name` only sets the download's filename; the server checks read access on
* the id. The route is `/files/signedurl` rather than `/files/file`, which
* answers the same thing as a redirect but also accepts `share`, the
* parameter that writes.
*/
async getFileManagerSignedUrl(fileId: string, name: string): Promise<string> {
if (!/^[0-9a-f]{24}$/i.test(fileId)) throw new Error(`not a file id: ${fileId}`);
const query = new URLSearchParams({ file: fileId, name: name || fileId });
const response = await this.legacyRequest(`/files/signedurl?${query.toString()}`, 'application/json');
// The server's error path *returns* its Forbidden rather than throwing it,
// so a refused file arrives as a 200 whose body has no url.
const body = (await response.json().catch(() => ({}))) as { url?: unknown; message?: unknown };
if (typeof body.url !== 'string' || !body.url) {
throw new SchulcloudApiError(403, '/files/signedurl', typeof body.message === 'string' ? body.message : 'no download url');
}
return body.url;
}
/** Downloads one legacy file: signed URL, then the bytes, capped like every download. */
async downloadFileManagerFile(fileId: string, name: string): Promise<DownloadedFile> {
const signed = await this.getFileManagerSignedUrl(fileId, name);
const response = await this.openSignedUrl(signed);
return this.readCapped(response, name);
}
/**
* Opens a pre-signed storage URL — with no credentials at all.
*
* The URL names another host (live: an S3 endpoint at the storage provider),
* so neither the bearer nor the cookie may go with it. It must also be
* https whenever the instance is, which keeps a URL the server hands back from
* pointing this process at a plaintext service on its own network.
*/
async openSignedUrl(signedUrl: string): Promise<Response> {
const target = checkSignedUrl(signedUrl, this.config.baseUrl);
return this.request(target, '*/*', 'none', { idleTimeout: true });
}
private async legacyRequest(path: string, accept: string): Promise<Response> {
try {
return await this.request(this.url(path), accept, 'cookie');
} catch (error) {
// The legacy client answers a rejected cookie with a redirect to its
// login page. Report it as what it is, so tools say "token expired"
// rather than "HTTP 302".
if (error instanceof SchulcloudApiError && error.status >= 300 && error.status < 400) {
throw new SchulcloudApiError(401, path, 'redirected to login: the session is not accepted');
}
throw error;
}
}
}
/**
* A timeout that measures silence rather than total time, for downloads.
*
* The request timeout is right for an API call and wrong for a file: it bounds
* the whole transfer, so an 11 MB scan from a slow storage host was cut off at
* 30 seconds while its bytes were still arriving — four files on the live
* account, recorded as failures. Here the clock starts over with every chunk,
* so only a transfer that stalls is abandoned.
*/
function idleDeadline(ms: number) {
const controller = new AbortController();
const expire = () => controller.abort(new Error(`no data received for ${Math.round(ms / 1000)}s`));
let timer = setTimeout(expire, ms);
// Never the reason a process stays alive: a caller that stops reading early
// (the download cap) leaves the last timer behind.
timer.unref();
const rearm = () => {
clearTimeout(timer);
timer = setTimeout(expire, ms);
timer.unref();
};
const stop = () => clearTimeout(timer);
const watch = (response: Response): Response => {
if (!response.body) {
stop();
return response;
}
rearm();
const body = response.body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, output) {
rearm();
output.enqueue(chunk);
},
flush() {
stop();
},
}),
);
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
};
return { signal: controller.signal, stop, watch };
}
/**
* The file-manager listing routes, and nothing else.
*
* Folders are addressed by id alone — `/files/courses/{course}/{folder}` holds
* one folder segment however deep the folder is — so every listing fits one of
* these shapes. `/files/my/{a}/{b}` exists too, but lists `b` exactly as
* `/files/my/{b}` does, so it is not needed.
*/
const FILE_MANAGER_PAGE =
/^\/files\/(?:(?:my|courses|teams|shared)\/|my\/[0-9a-f]{24}|(?:courses|teams)\/[0-9a-f]{24}(?:\/[0-9a-f]{24})?)$/i;
/** Validates a pre-signed URL before anything is sent to it. Exported for testing. */
export function checkSignedUrl(signedUrl: string, baseUrl: string): URL {
let target: URL;
try {
target = new URL(signedUrl);
} catch {
throw new Error('the download url the server returned is not a url');
}
const instanceIsHttps = new URL(baseUrl).protocol === 'https:';
const allowed = instanceIsHttps ? ['https:'] : ['https:', 'http:'];
if (!allowed.includes(target.protocol)) {
throw new Error(`refusing a ${target.protocol} download url from an ${instanceIsHttps ? 'https' : 'http'} instance`);
}
if (target.username || target.password) {
throw new Error('refusing a download url that carries credentials');
}
return target;
}
/**

View File

@@ -1,8 +1,14 @@
import type { Config } from '../config.ts';
import { h5pSearchText } from './h5p.ts';
import { assembleBoard, type AssembledBoard } from './board.ts';
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 { CourseMetadata, FileRecord, TaskContent } from './types.ts';
import type { LessonLogEntry } from './untis-history.ts';
import type { CourseMetadata, FileParentType, FileRecord, TaskContent } from './types.ts';
/**
* Walks an account's entire content tree and returns it as one snapshot.
@@ -17,7 +23,14 @@ import type { CourseMetadata, FileRecord, TaskContent } from './types.ts';
* a separate pass keyed off the file records collected here.
*/
/** Where an item sits, for building mirror paths and human-readable hits. */
/**
* Where an item sits, for building mirror paths and human-readable hits.
*
* `courseId`/`courseTitle` name the *container*, which since rooms were added
* is a course or a room. The names are kept because they are also the manifest
* wire format the CLI reads; renaming them would break older clients for no
* gain here.
*/
export interface Breadcrumb {
courseId: string;
courseTitle: string;
@@ -26,14 +39,48 @@ export interface Breadcrumb {
/** Column → card, for board files. */
columnTitle?: string;
cardTitle?: string;
/**
* Folder names from the file manager, outermost first. Unlike boards, its
* trees have no fixed depth, so they cannot be squeezed into the titles above.
*/
folders?: string[];
}
export interface CrawledFile {
record: FileRecord;
/** Board element, lesson or task this file hangs off. */
parentType: 'boardnodes' | 'lessons' | 'tasks';
/** What this file hangs off. */
parentType: FileParentType;
parentId: string;
at: Breadcrumb;
/**
* Which store holds the bytes. The file manager is not files-storage: its
* ids mean nothing to /api/v3/file, so downloads must be routed by this.
*/
source?: 'files-storage' | 'file-manager';
/** The file-manager path fs_read takes, for files from there. */
fsPath?: string;
}
/**
* One submission, with whatever the page could tell us about its grading.
*
* Indexed so that "what did the teacher say about X" and "what was graded this
* week" are answerable at all: the submission endpoints carry no text and no
* timestamps, so without this the whole grading surface is invisible to search
* and to what_changed.
*/
export interface CrawledSubmission {
id: string;
taskId: string;
taskName: string;
courseId: string;
courseTitle: string;
isSubmitted: boolean;
isGraded: boolean;
grade?: number | null;
gradeComment?: string;
submittedText?: string;
submitterIds: string[];
}
export interface CrawledBoard {
@@ -70,11 +117,44 @@ export interface CrawledCourse {
tasks: CrawledTask[];
}
/**
* A room and its boards.
*
* Rooms hold boards and nothing else — no lessons, no tasks — so this is
* deliberately thinner than CrawledCourse rather than a course with empty
* fields.
*/
export interface CrawledRoom {
id: string;
name: string;
boards: CrawledBoard[];
}
export interface Snapshot {
crawledAt: Date;
schoolId: string;
courses: CrawledCourse[];
/** Rooms ("Räume"), a separate space from courses. */
rooms: CrawledRoom[];
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
@@ -85,17 +165,39 @@ export interface Snapshot {
export interface CrawlOptions {
schoolId: string;
/** The account's own user id — needed to reach its personal files. */
userId?: string;
/** Restrict to these courses. Omit for everything the account can see. */
courseIds?: string[];
/** Fetch lesson bodies too. Costs one request per lesson. */
includeLessonContents?: boolean;
/** Resolve board file elements to file records. */
includeFiles?: boolean;
/**
* Also index the account's personal files and its submitted / returned work.
*
* Off by default because of what it costs: submissions are per task, so this
* adds roughly three requests per task on top of a crawl that is already the
* expensive part of this server. Worth it when you want "what did I write
* about X" to be searchable, which is otherwise impossible.
*/
includePersonalFiles?: boolean;
/**
* Walk the file manager ("Dateien") too: Kurs-Dateien for every course, and on
* a full crawl Persönliche Dateien, Team-Dateien and Geteilte Dateien. One
* page load per folder — on the account this was built for, about 160.
*/
includeFileManager?: boolean;
/**
* Read the text of collaborative text editor (Etherpad) pads, which needs a
* 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;
@@ -111,14 +213,30 @@ export async function crawl(client: SchulcloudClient, options: CrawlOptions): Pr
const crawled: CrawledCourse[] = [];
const files: CrawledFile[] = [];
const submissions: CrawledSubmission[] = [];
const failures: { courseId: string; boardId?: string; reason: string }[] = [];
let done = 0;
// Personal files ("Meine Dateien") hang off the user, not off any course, so
// nothing in the course walk would ever reach them. One request, and only on
// a full crawl — a per-course refresh has no business rewriting them.
if (includeFiles && options.includePersonalFiles && !options.courseIds && options.userId) {
for (const record of await listFiles(client, options.schoolId, 'users', options.userId)) {
files.push({
record,
parentType: 'users',
parentId: options.userId,
at: { courseId: '', courseTitle: 'My files' },
});
}
}
await forEachLimited(courses, options.courseConcurrency ?? 5, async (course) => {
try {
const result = await crawlCourse(client, course, options, includeFiles, includeLessons);
crawled.push(result.course);
files.push(...result.files);
submissions.push(...result.submissions);
failures.push(...result.failures);
} catch (error) {
failures.push({ courseId: course.id, reason: error instanceof Error ? error.message : String(error) });
@@ -127,12 +245,164 @@ export async function crawl(client: SchulcloudClient, options: CrawlOptions): Pr
}
});
// Rooms are a separate space from courses and are only walked on a full
// crawl: a per-course refresh names a course, and the store carries anything
// outside that scope forward untouched.
const rooms: CrawledRoom[] = options.courseIds ? [] : await crawlRooms(client, options, includeFiles, files, failures);
if (includeFiles && options.includeFileManager) {
const titles = new Map(crawled.map((entry) => [entry.course.id, entry.title]));
await crawlFileManager(client, options, titles, files, failures);
}
// Traversal order is nondeterministic under concurrency; sort so that two
// crawls of unchanged content produce identical snapshots.
crawled.sort((a, b) => a.course.id.localeCompare(b.course.id));
files.sort((a, b) => a.record.id.localeCompare(b.record.id));
rooms.sort((a, b) => a.id.localeCompare(b.id));
// One file can be reachable twice — a course file someone also shared with
// you appears under /shared as well — and the index keys nodes by id. Keep
// the first place it was found, which the traversal order makes the most
// specific one.
const seenFiles = new Set<string>();
const uniqueFiles = files.filter((file) => (seenFiles.has(file.record.id) ? false : (seenFiles.add(file.record.id), true)));
files.length = 0;
files.push(...uniqueFiles);
return { crawledAt: new Date(), schoolId: options.schoolId, courses: crawled, files, failures };
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,
courses: crawled,
rooms,
files,
submissions,
notes,
lessonLog: [],
failures,
};
}
/**
* Walks the rooms this account belongs to.
*
* A room holds only boards. Unpublished ones are skipped rather than attempted:
* the room board listing reports `isVisible`, so unlike a course board there is
* no need to try one and record the resulting 403 as a failure.
*
* A room being unreadable is recorded, not swallowed — same rule as boards.
*/
async function crawlRooms(
client: SchulcloudClient,
options: CrawlOptions,
includeFiles: boolean,
files: CrawledFile[],
failures: { courseId: string; boardId?: string; reason: string }[],
): Promise<CrawledRoom[]> {
let listed;
try {
listed = await client.listRooms();
} catch (error) {
failures.push({ courseId: '(rooms)', reason: error instanceof Error ? error.message : String(error) });
return [];
}
const rooms: CrawledRoom[] = [];
await forEachLimited(listed, options.courseConcurrency ?? 5, async (room) => {
const boards: CrawledBoard[] = [];
try {
const listing = await client.listRoomBoards(room.id);
const readable = listing.filter((board) => board.isVisible !== false).map((board) => board.id);
await crawlBoards(client, options, { id: room.id, title: room.name }, readable, includeFiles, boards, files, failures);
} catch (error) {
failures.push({ courseId: room.id, reason: error instanceof Error ? error.message : String(error) });
}
boards.sort((a, b) => a.id.localeCompare(b.id));
rooms.push({ id: room.id, name: room.name, boards });
});
return rooms;
}
/**
* Assembles a set of boards into a container — a course or a room.
*
* Shared so that rooms are not a second, quietly divergent traversal: when pads
* or file handling change here, both get it.
*/
async function crawlBoards(
client: SchulcloudClient,
options: CrawlOptions,
container: { id: string; title: string },
boardIds: string[],
includeFiles: boolean,
boards: CrawledBoard[],
files: CrawledFile[],
failures: { courseId: string; boardId?: string; reason: string }[],
): Promise<void> {
await forEachLimited(boardIds, options.boardConcurrency ?? 4, async (boardId) => {
let assembled: AssembledBoard;
try {
assembled = await assembleBoard(client, boardId, options.schoolId, {
resolveFiles: includeFiles,
resolvePads: options.config,
resolveH5p: true,
});
} catch (error) {
// Record rather than swallow: a dropped board used to disappear from the
// index while the crawl still reported success, which is how a 20-card
// query limit went unnoticed.
failures.push({
courseId: container.id,
boardId,
reason: error instanceof Error ? error.message : String(error),
});
return;
}
const parts: string[] = [];
for (const column of assembled.columns) {
for (const card of column.cards) {
parts.push(card.title);
for (const element of card.elements) {
if (element.text) parts.push(htmlToText(element.text));
// Pad contents are course material like any other; without this they
// are unsearchable, and a pad is often where the actual group work is.
if (element.padText) parts.push(element.padText);
// A quiz's questions are material too: without this, searching for
// something a teacher only asked in an H5P exercise finds nothing.
if (element.h5p) parts.push(h5pSearchText(element.h5p));
if (element.url) parts.push(element.url);
for (const record of element.files) {
files.push({
record,
parentType: 'boardnodes',
parentId: element.id,
at: {
courseId: container.id,
courseTitle: container.title,
containerTitle: assembled.title,
columnTitle: column.title,
cardTitle: card.title,
},
});
}
}
}
}
boards.push({
id: assembled.id,
title: assembled.title,
courseId: container.id,
board: assembled,
text: parts.filter(Boolean).join('\n'),
});
});
}
async function crawlCourse(
@@ -141,10 +411,16 @@ async function crawlCourse(
options: CrawlOptions,
includeFiles: boolean,
includeLessons: boolean,
): Promise<{ course: CrawledCourse; files: CrawledFile[]; failures: { courseId: string; boardId: string; reason: string }[] }> {
): Promise<{
course: CrawledCourse;
files: CrawledFile[];
submissions: CrawledSubmission[];
failures: { courseId: string; boardId: string; reason: string }[];
}> {
const page = await client.getCourseBoard(course.id);
const title = page.title || course.title;
const files: CrawledFile[] = [];
const submissions: CrawledSubmission[] = [];
const failures: { courseId: string; boardId: string; reason: string }[] = [];
const boards: CrawledBoard[] = [];
@@ -167,6 +443,9 @@ async function crawlCourse(
at: { courseId: course.id, courseTitle: title, containerTitle: element.content.name },
});
}
if (options.includePersonalFiles) {
await collectSubmissions(client, options, course.id, title, element.content, files, submissions);
}
}
} else if (element.type === 'lesson') {
const lesson: CrawledLesson = {
@@ -193,6 +472,45 @@ async function crawlCourse(
}
}
lessons.push(lesson);
// Tasks attached to a topic are not task elements on the course page,
// so nothing above reaches them — and once past due they are absent
// from both task lists too. On the account this was built for that is
// 18 of 60 tasks: without this they are unsearchable and their grades
// are invisible. The ids only exist on the topic page (lesson-page.ts).
if (options.config && element.content.numberOfPublishedTasks) {
const [linked, links] = await Promise.all([
client.getLessonTasks(element.content.id).catch(() => []),
fetchLessonTaskLinks(options.config, course.id, element.content.id),
]);
for (const linkedTask of withScrapedIds(linked, links)) {
if (!linkedTask.id) continue;
const body = htmlToText(linkedTask.description);
const asTask: TaskContent = {
id: linkedTask.id,
name: linkedTask.name,
courseId: course.id,
courseName: title,
lessonName: element.content.name,
description: linkedTask.description,
dueDate: linkedTask.dueDate ?? null,
availableDate: linkedTask.availableDate,
status: {
submitted: 0,
maxSubmissions: 0,
graded: 0,
isDraft: false,
isSubstitutionTeacher: false,
isFinished: false,
},
};
tasks.push({ id: linkedTask.id, courseId: course.id, task: asTask, text: body });
if (includeFiles && options.includePersonalFiles) {
await collectSubmissions(client, options, course.id, title, asTask, files, submissions);
}
}
}
if (includeFiles) {
for (const record of await listFiles(client, options.schoolId, 'lessons', element.content.id)) {
files.push({
@@ -206,69 +524,216 @@ async function crawlCourse(
}
}
await forEachLimited(boardIds, options.boardConcurrency ?? 4, async (boardId) => {
let assembled: AssembledBoard;
try {
assembled = await assembleBoard(client, boardId, options.schoolId, {
resolveFiles: includeFiles,
resolvePads: options.config,
// The course's own file area ("Dateien" on the course page). One request per
// course, and previously invisible: these files were reachable with
// list_files but never indexed, so search and `schulcloud sync` missed them.
if (includeFiles) {
for (const record of await listFiles(client, options.schoolId, 'courses', course.id)) {
files.push({
record,
parentType: 'courses',
parentId: course.id,
at: { courseId: course.id, courseTitle: title, containerTitle: 'Course files' },
});
} catch (error) {
// Record rather than swallow: a dropped board used to disappear from the
// index while the crawl still reported success, which is how a 20-card
// query limit went unnoticed.
failures.push({
courseId: course.id,
boardId,
reason: error instanceof Error ? error.message : String(error),
});
return;
}
}
const parts: string[] = [];
for (const column of assembled.columns) {
for (const card of column.cards) {
parts.push(card.title);
for (const element of card.elements) {
if (element.text) parts.push(htmlToText(element.text));
// Pad contents are course material like any other; without this they
// are unsearchable, and a pad is often where the actual group work is.
if (element.padText) parts.push(element.padText);
if (element.url) parts.push(element.url);
for (const record of element.files) {
files.push({
record,
parentType: 'boardnodes',
parentId: element.id,
at: {
courseId: course.id,
courseTitle: title,
containerTitle: assembled.title,
columnTitle: column.title,
cardTitle: card.title,
},
});
}
}
}
}
boards.push({
id: assembled.id,
title: assembled.title,
courseId: course.id,
board: assembled,
text: parts.filter(Boolean).join('\n'),
});
});
await crawlBoards(client, options, { id: course.id, title }, boardIds, includeFiles, boards, files, failures);
boards.sort((a, b) => a.id.localeCompare(b.id));
return { course: { course, title, boards, lessons, tasks }, files, failures };
return { course: { course, title, boards, lessons, tasks }, files, submissions, failures };
}
/**
* What was handed in for one task, and what the teacher handed back.
*
* Both hang off the *submission* id, which is only obtainable from the status
* endpoint — there is no submissions list. Note the asymmetry the file service
* has here: listing with `parentType: 'gradings'` returns records whose own
* `parentType` is `submissions`, so each record is filed under what it says it
* is rather than under the path it was asked for. Without that split a
* student's own upload would be indexed as teacher feedback.
*/
async function collectSubmissions(
client: SchulcloudClient,
options: CrawlOptions,
courseId: string,
courseTitle: string,
task: TaskContent,
files: CrawledFile[],
submissions: CrawledSubmission[],
): Promise<void> {
const statuses = await client.listSubmissionStatuses(task.id).catch(() => []);
if (statuses.length === 0) return;
// The grade comment and the submitted text exist only on the rendered page,
// so one fetch per task covers every submission on it.
const page = options.config
? await fetchHomeworkPage(options.config, task.id).catch(() => undefined)
: undefined;
for (const status of statuses) {
const grading = page?.grading.find((entry) => entry.submissionId === status.id);
submissions.push({
id: status.id,
taskId: task.id,
taskName: task.name,
courseId,
courseTitle,
isSubmitted: status.isSubmitted,
isGraded: status.isGraded,
grade: status.grade ?? grading?.gradePercent ?? null,
gradeComment: grading?.gradeComment ?? page?.own?.gradeComment,
submittedText: page?.own?.submittedText,
submitterIds: status.submitters,
});
}
for (const status of statuses) {
for (const parentType of ['submissions', 'gradings'] as const) {
for (const record of await listFiles(client, options.schoolId, parentType, status.id)) {
files.push({
record,
parentType: record.parentType ?? parentType,
parentId: status.id,
at: {
courseId,
courseTitle,
containerTitle: task.name,
cardTitle: record.parentType === 'gradings' ? 'Returned by the teacher' : 'Handed in',
},
});
}
}
}
}
/**
* Walks the file manager and records every file in it.
*
* Kurs-Dateien are filed under their course, so a per-course refresh replaces
* exactly that course's files and the store's scope rules carry the rest
* forward. The areas that belong to no course — personal, team, shared — are
* walked only on a full crawl, for the same reason.
*
* A fresh FileManager rather than the process-wide one: its listing cache is
* right for an interactive ls-then-read, and wrong for a crawl whose whole
* point is to see the current state.
*/
async function crawlFileManager(
client: SchulcloudClient,
options: CrawlOptions,
courseTitles: Map<string, string>,
files: CrawledFile[],
failures: { courseId: string; boardId?: string; reason: string }[],
): Promise<void> {
const manager = new FileManager(client);
const roots: { ref: DirectoryRef; path: string; courseId: string; at: Omit<Breadcrumb, 'folders'> }[] = [];
if (options.courseIds) {
for (const courseId of options.courseIds) {
const title = courseTitles.get(courseId) ?? courseId;
roots.push({
ref: { area: 'courses', ownerId: courseId },
path: `/courses/${title}`,
courseId,
at: { courseId, courseTitle: title, containerTitle: 'Kurs-Dateien' },
});
}
} else {
const owners = async (area: 'courses' | 'teams') => {
try {
return (await manager.list({ area })).directories;
} catch (error) {
failures.push({ courseId: '', reason: `file manager /${area}: ${error instanceof Error ? error.message : String(error)}` });
return [];
}
};
for (const course of await owners('courses')) {
const title = courseTitles.get(course.id) ?? course.name;
roots.push({
ref: { area: 'courses', ownerId: course.id },
path: `/courses/${course.name}`,
courseId: course.id,
at: { courseId: course.id, courseTitle: title, containerTitle: 'Kurs-Dateien' },
});
}
for (const team of await owners('teams')) {
roots.push({
ref: { area: 'teams', ownerId: team.id },
path: `/teams/${team.name}`,
courseId: '',
at: { courseId: '', courseTitle: 'Team-Dateien', containerTitle: team.name },
});
}
roots.push({ ref: { area: 'my' }, path: '/my', courseId: '', at: { courseId: '', courseTitle: 'Persönliche Dateien' } });
roots.push({ ref: { area: 'shared' }, path: '/shared', courseId: '', at: { courseId: '', courseTitle: 'Geteilte Dateien' } });
}
// Sequential roots, modest concurrency within each: the instance answers a
// burst with 503s, and this walk is the largest single part of a crawl.
for (const root of roots) {
const result = await manager.walk(
{ path: root.path, ref: root.ref },
{ maxDepth: 25, maxDirectories: 5000, concurrency: 2 },
);
for (const failure of result.failures) {
failures.push({ courseId: root.courseId, reason: `file manager ${failure.path}: ${failure.reason}` });
}
if (result.truncated) {
failures.push({ courseId: root.courseId, reason: `file manager ${root.path}: stopped at 5000 folders` });
}
// Folder names come from the parent chain, never from splitting a path:
// names contain "/" in real data.
const folders = new Map<string, string[]>([[root.path, []]]);
const ordered = [...result.entries].sort((a, b) => a.depth - b.depth);
for (const entry of ordered) {
if (entry.directory) {
const parent = folders.get(entry.parentPath);
if (parent) folders.set(entry.path, [...parent, entry.directory.name]);
}
}
for (const entry of ordered) {
if (!entry.file) continue;
files.push(fileManagerRecord(entry, entry.file, root, folders.get(entry.parentPath) ?? []));
}
}
}
function fileManagerRecord(
entry: WalkEntry,
file: FmFile,
root: { ref: DirectoryRef; courseId: string; at: Omit<Breadcrumb, 'folders'> },
folders: string[],
): CrawledFile {
const parentType: FileParentType = root.ref.area === 'courses' ? 'courses' : 'users';
return {
// Shaped like a files-storage record so the store, the mirror and the
// manifest need no second code path; `source` is what keeps the two apart.
record: {
id: file.id,
name: file.name,
parentId: entry.parent.folderId ?? entry.parent.ownerId ?? '',
parentType,
url: '',
size: file.size,
mimeType: file.mimeType ?? 'application/octet-stream',
securityCheckStatus: file.blocked ? 'blocked' : 'verified',
previewStatus: '',
},
parentType,
parentId: entry.parent.folderId ?? entry.parent.ownerId ?? '',
at: { ...root.at, folders },
source: 'file-manager',
fsPath: entry.path,
};
}
async function listFiles(
client: SchulcloudClient,
schoolId: string,
parentType: 'lessons' | 'tasks',
parentType: FileParentType,
parentId: string,
): Promise<FileRecord[]> {
const page = await client

86
src/core/dates.ts Normal file
View File

@@ -0,0 +1,86 @@
/**
* Calendar dates in the school's timezone.
*
* The container runs on UTC and the school does not: at 00:30 in Erfurt it is
* still yesterday in UTC, so "today's timetable" derived from the process clock
* would fetch the wrong day twice a night. Every date a person or the timetable
* API sees is derived here instead, in Europe/Berlin.
*
* Dates are plain `YYYY-MM-DD` strings on purpose. A `Date` carries a time and
* a zone, which is exactly what a school day does not have.
*/
export const SCHOOL_TIME_ZONE = 'Europe/Berlin';
/** `YYYY-MM-DD`, in the school's timezone. */
export function schoolToday(now: Date = new Date()): string {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: SCHOOL_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(now);
const part = (type: Intl.DateTimeFormatPartTypes): string => parts.find((p) => p.type === type)?.value ?? '';
return `${part('year')}-${part('month')}-${part('day')}`;
}
/**
* True for a date that exists.
*
* The round trip is the point: `Date.parse` turns `2026-02-30` into March 2nd
* rather than rejecting it, so a shape check alone would let a tool read a
* different day than the caller asked for.
*/
export function isCalendarDate(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const at = Date.parse(`${value}T12:00:00Z`);
return !Number.isNaN(at) && new Date(at).toISOString().slice(0, 10) === value;
}
function noonUtc(date: string): number {
const at = Date.parse(`${date}T12:00:00Z`);
if (Number.isNaN(at)) throw new Error(`Not a YYYY-MM-DD date: ${date}`);
return at;
}
/**
* Shifts a calendar date by whole days.
*
* Anchored at noon UTC so that no daylight-saving change can push the result
* onto the neighbouring day — the arithmetic stays on calendar dates.
*/
export function addDays(date: string, days: number): string {
return new Date(noonUtc(date) + days * 86_400_000).toISOString().slice(0, 10);
}
/** Whole days from `from` to `to`; negative when `to` is earlier. */
export function daysBetween(from: string, to: string): number {
return Math.round((noonUtc(to) - noonUtc(from)) / 86_400_000);
}
/** `2026-09-21` → `Montag`. */
export function germanWeekday(date: string): string {
return new Intl.DateTimeFormat('de-DE', { weekday: 'long', timeZone: 'UTC' }).format(new Date(noonUtc(date)));
}
/** `2026-09-21` → `21.09.2026`. */
export function germanDay(date: string): string {
const [year, month, day] = date.split('-');
return `${day}.${month}.${year}`;
}
/** A moment → `Dienstag, 15.09.2026` in the school's timezone. */
export function germanDate(date: Date): string {
return new Intl.DateTimeFormat('de-DE', {
weekday: 'long',
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: SCHOOL_TIME_ZONE,
}).format(date);
}
/** `2026-09-21` → `20260921`, the compact form the WebUntis API takes. */
export function compactDate(date: string): number {
return Number(date.replace(/-/g, ''));
}

117
src/core/day-note.ts Normal file
View File

@@ -0,0 +1,117 @@
import { germanDay, germanWeekday } from './dates.ts';
import type { UntisLesson, UntisTimetable } from './untis.ts';
/**
* A school day as a note: one file, one heading per lesson.
*
* This is where the two systems meet for the third one. WebUntis is the only
* place that knows which lessons a day actually holds — including that the
* third period was cancelled and the fourth is a substitution — so a page that
* asks someone to write up their day can hand them the day already laid out
* instead of an empty box. The headings it writes are the ones
* `subjectFromHeading` reads back, which is what makes each lesson separately
* searchable afterwards.
*/
export interface DayLesson {
/** The heading text, without its `##`. */
heading: string;
subject?: string;
start: string;
end: string;
periodId: number;
/** A substitution: worth knowing while writing, since the teacher differs. */
changed: boolean;
}
/** `Montag, 15.09.2026` — what a day note is called. */
export function dayNoteTitle(date: string): string {
return `${germanWeekday(date)}, ${germanDay(date)}`;
}
/**
* One lesson's heading: `1. DE — 08:0008:45 · Meier · R 204`.
*
* The subject leads, because that is the part a person scans for and the part
* the parser reads back. Everything after the first `·` is context and may be
* edited away without breaking anything.
*/
export function lessonHeading(lesson: UntisLesson, index: number): string {
const subject = lesson.subjects[0];
const name = subject?.longName || subject?.name || 'Stunde';
const teachers = lesson.teachers.map((teacher) => teacher.name).join(', ');
const rooms = lesson.rooms.map((room) => room.name).join(', ');
return [
`${index + 1}. ${name}${lesson.start}${lesson.end}`,
teachers || undefined,
rooms ? `R ${rooms}` : undefined,
lesson.changed ? 'Vertretung' : undefined,
]
.filter(Boolean)
.join(' · ');
}
/**
* The day's lessons, in order, as headings.
*
* Cancelled periods are left out: nothing was taught in them, and a heading
* with nothing under it is worse than no heading. A substitution is kept and
* marked, because it did happen and its teacher is not the usual one.
*/
export function dayLessons(timetable: UntisTimetable, date: string): DayLesson[] {
const day = timetable.days.find((entry) => entry.date === date);
const held = (day?.lessons ?? []).filter((lesson) => !lesson.cancelled);
return held.map((lesson, index) => {
const subject = lesson.subjects[0];
return {
heading: lessonHeading(lesson, index),
...(subject?.longName || subject?.name ? { subject: subject!.longName || subject!.name } : {}),
start: lesson.start,
end: lesson.end,
periodId: lesson.periodId,
changed: lesson.changed,
};
});
}
/**
* The starting text for a day's note: a heading per lesson, blank beneath.
*
* Blank rather than prompted — a placeholder line would have to be deleted in
* every lesson of every day, and half of them would survive into the note.
*/
export function dayNoteSkeleton(lessons: DayLesson[]): string {
if (lessons.length === 0) return '';
return `${lessons.map((lesson) => `## ${lesson.heading}\n`).join('\n')}`;
}
/**
* The headings a note is missing, for a day whose timetable is known.
*
* Someone who starts a note before the day ends, or whose timetable changed
* after they started, should be able to top it up without losing what they
* wrote — so the page adds what is absent rather than rebuilding the note.
*/
export function missingHeadings(text: string, lessons: DayLesson[]): DayLesson[] {
const present = new Set(
text
.split('\n')
.map((line) => /^##\s+(.*\S)\s*$/.exec(line)?.[1])
.filter((heading): heading is string => Boolean(heading))
.map(headingKey),
);
return lessons.filter((lesson) => !present.has(headingKey(lesson.heading)));
}
/**
* What makes two headings "the same lesson".
*
* The period number and the subject, ignoring everything a person may have
* rewritten — a heading edited from `2. DE — 08:5009:35 · Meier` down to
* `2. Deutsch` is still the second period, and adding it again would give the
* day two.
*/
function headingKey(heading: string): string {
const match = /^\s*(\d{1,2})\s*[.)]/.exec(heading);
return match ? `#${match[1]}` : heading.trim().toLowerCase().replace(/\s+/g, ' ');
}

View File

@@ -98,3 +98,57 @@ export function padIdFromUrl(url: string, baseUrl: string): string | undefined {
const segment = /\/etherpad\/p\/([^/?#]+)/.exec(parsed.pathname)?.[1];
return segment && segment.length > 0 ? segment : undefined;
}
/**
* The text of a pad linked from a *topic* ("Thema"), as opposed to a board.
*
* Topics reach Etherpad differently from column boards: there is no
* `collaborative-text-editor` element to ask, only a stored pad url on the
* lesson component. The session cookie comes from the topic page instead —
* the legacy client requests an Etherpad session on every topic page whose
* lesson has contents, whether or not a pad is present, so simply rendering
* the page yields one. Everything here is a GET.
*
* The stored url is data and may point anywhere; `padIdFromUrl` refuses any
* host but this instance's, so a pad recorded against another deployment is
* reported as a link rather than fetched — which is the correct outcome, not
* a failure.
*/
export async function fetchLessonPadText(
config: Config,
courseId: string,
lessonId: string,
padUrl: string,
): Promise<string | undefined> {
const padId = padIdFromUrl(padUrl, config.baseUrl);
if (!padId) return undefined;
try {
const page = await fetch(
`${config.baseUrl}/courses/${encodeURIComponent(courseId)}/topics/${encodeURIComponent(lessonId)}`,
{
headers: { Cookie: `jwt=${config.jwt}`, Accept: 'text/html' },
signal: AbortSignal.timeout(config.requestTimeoutMs),
redirect: 'follow',
},
);
if (!page.ok) return undefined;
const sessionCookie = page.headers
.getSetCookie()
.map((cookie) => /^(sessionID=[^;]*)/.exec(cookie)?.[1])
.find((value): value is string => Boolean(value));
if (!sessionCookie) return undefined;
const response = await fetch(`${new URL(config.baseUrl).origin}/etherpad/p/${padId}/export/txt`, {
headers: { Cookie: sessionCookie, Accept: 'text/plain' },
signal: AbortSignal.timeout(config.requestTimeoutMs),
});
if (!response.ok) return undefined;
const text = (await response.text()).trim();
return text.length > 0 && text !== DEFAULT_PAD_TEXT ? text : undefined;
} catch {
return undefined;
}
}

View File

@@ -140,7 +140,10 @@ function hasTextLayer(bytes: Buffer): boolean {
async function extractPdf(bytes: Buffer): Promise<string> {
const { extractText, getDocumentProxy } = await import('unpdf');
const document = await getDocumentProxy(new Uint8Array(bytes));
// verbosity 0 = errors only. pdf.js otherwise prints "Warning: TT: undefined
// function" for every font hint it skips — harmless, but a crawl of the file
// manager extracts hundreds of PDFs, and that buries the log in noise.
const document = await getDocumentProxy(new Uint8Array(bytes), { verbosity: 0 });
const { text } = await extractText(document, { mergePages: true });
return Array.isArray(text) ? text.join('\n\n') : text;
}

305
src/core/h5p.ts Normal file
View File

@@ -0,0 +1,305 @@
/**
* Reads H5P content: the quizzes and interactive exercises on a board.
*
* Schulcloud has no quiz of its own — an exercise is an H5P element, and the
* board API hands over nothing but a `contentId`. The player then shows one
* question at a time, which makes a quiz look like something that has to be
* stepped through or scraped. It is not: `GET /api/v3/h5p-editor/params/{id}`
* returns the entire exercise as the JSON the player is fed, so one request
* holds every question, every option and which of them is correct.
*
* What varies is the *shape* of that JSON, because it belongs to whichever H5P
* library the teacher used. The common question types are modelled below; for
* anything else the text is harvested generically and labelled as such, so an
* exercise this parser does not know still arrives readable instead of empty.
*/
import type { SchulcloudClient } from './client.ts';
import { htmlToText } from './text.ts';
export interface H5pAnswer {
text: string;
/** Undefined when the library does not say — never guess a solution. */
correct?: boolean;
tip?: string;
}
export interface H5pQuestion {
/** The H5P library, e.g. `H5P.MultiChoice 1.16`. */
library: string;
/** A short human name for the kind of task, for the reader. */
kind: string;
text: string;
answers: H5pAnswer[];
/** True when more than one option is meant to be ticked. */
multiple?: boolean;
/** The task's own text where it is not the question: a cloze, a description. */
body?: string;
}
export interface H5pContent {
contentId: string;
title: string;
/** The main library of the content itself, e.g. `H5P.QuestionSet`. */
library: string;
intro?: string;
/** Percentage needed to pass, when the content sets one. */
passPercentage?: number;
questions: H5pQuestion[];
/**
* Text from parts this parser does not model. Never silently dropped: a
* teacher's exercise turning up as "0 questions" is the bug this avoids.
*/
unmodelled: string[];
}
/** Fetches and parses one H5P content. Throws if it cannot be read or parsed. */
export async function readH5pContent(client: SchulcloudClient, contentId: string): Promise<H5pContent> {
return parseH5pParams(contentId, await client.getH5pParams(contentId));
}
/** Everything in the content as one string, for the search index. */
export function h5pSearchText(content: H5pContent): string {
const parts = [content.title, content.intro];
for (const question of content.questions) {
parts.push(question.text, question.body, ...question.answers.map((answer) => answer.text));
}
parts.push(...content.unmodelled);
return parts.filter((part): part is string => Boolean(part?.trim())).join('\n');
}
/**
* Interprets a `params` payload.
*
* Throws on a payload that is not H5P at all rather than returning an empty
* exercise — the difference between "this quiz has no questions" and "the
* format changed" matters, and only one of them is worth reporting as content.
*/
export function parseH5pParams(contentId: string, payload: unknown): H5pContent {
const root = asRecord(payload);
const metadata = asRecord(root?.h5p);
// `params.params` is the content itself; `params.metadata` beside it repeats
// the H5P metadata. A single-question content has the same shape.
const outer = asRecord(root?.params);
const params = asRecord(outer?.params) ?? outer;
const library = string(metadata?.mainLibrary) ?? string(root?.library) ?? 'unknown';
if (!params) {
throw new Error(`H5P content ${contentId} carried no params — the payload shape has changed`);
}
const title = string(metadata?.title)?.trim() || 'H5P content';
const content: H5pContent = { contentId, title, library, questions: [], unmodelled: [] };
const introPage = asRecord(params.introPage);
const intro = htmlToText(string(introPage?.introduction) ?? string(params.intro) ?? '').trim();
if (intro) content.intro = intro;
if (typeof params.passPercentage === 'number') content.passPercentage = params.passPercentage;
// A QuestionSet holds a list of sub-contents, each with its own library; any
// other library *is* the single question.
const questions = Array.isArray(params.questions) && params.questions.some((entry) => asRecord(entry)?.library)
? params.questions
: undefined;
if (questions) {
for (const entry of questions) {
const record = asRecord(entry);
const sub = asRecord(record?.params);
const subLibrary = string(record?.library) ?? 'unknown';
if (sub) content.questions.push(...parseQuestion(subLibrary, sub, content.unmodelled));
}
} else {
content.questions.push(...parseQuestion(library, params, content.unmodelled));
}
return content;
}
/**
* One sub-content as questions.
*
* Returns several for the libraries that bundle them (SingleChoiceSet,
* Summary, Blanks) and none for one it cannot read, in which case its text goes
* to `unmodelled`.
*/
function parseQuestion(library: string, params: Record<string, unknown>, unmodelled: string[]): H5pQuestion[] {
const name = library.split(/\s+/)[0] ?? library;
const question = (text: string, answers: H5pAnswer[], extra: Partial<H5pQuestion> = {}): H5pQuestion => ({
library,
kind: KIND[name] ?? name.replace(/^H5P\./, ''),
text,
answers,
...extra,
});
switch (name) {
case 'H5P.MultiChoice': {
const answers = (asArray(params.answers) ?? []).map((entry): H5pAnswer => {
const answer = asRecord(entry);
const tip = htmlToText(string(asRecord(answer?.tipsAndFeedback)?.tip) ?? '').trim();
return {
text: htmlToText(string(answer?.text) ?? '').trim(),
correct: answer?.correct === true,
...(tip ? { tip } : {}),
};
});
// `singleAnswer` is what the player uses to choose radio buttons over
// checkboxes, and it is the only honest way to say "tick one".
const single = asRecord(params.behaviour)?.singleAnswer === true;
return [
question(htmlToText(string(params.question) ?? '').trim(), answers, {
multiple: !single && answers.filter((answer) => answer.correct).length !== 1,
}),
];
}
case 'H5P.TrueFalse': {
const l10n = asRecord(params.l10n);
const yes = string(l10n?.trueText) ?? 'Wahr';
const no = string(l10n?.falseText) ?? 'Falsch';
// `correct` is the string "true" or "false", not a boolean.
const correct = string(params.correct);
return [
question(htmlToText(string(params.question) ?? '').trim(), [
{ text: yes, correct: correct === 'true' },
{ text: no, correct: correct === 'false' },
]),
];
}
case 'H5P.Blanks': {
const description = htmlToText(string(params.text) ?? '').trim();
return (asArray(params.questions) ?? []).flatMap((entry) => {
const raw = string(entry);
if (!raw) return [];
const { text, answers } = parseCloze(raw);
return [question(text, answers, { body: description || undefined })];
});
}
case 'H5P.DragText':
case 'H5P.MarkTheWords': {
const raw = string(params.textField) ?? '';
const { text, answers } = parseCloze(raw);
const description = htmlToText(string(params.taskDescription) ?? '').trim();
return [question(description || 'Aufgabe', answers, { body: text })];
}
case 'H5P.SingleChoiceSet': {
return (asArray(params.choices) ?? []).flatMap((entry) => {
const choice = asRecord(entry);
const texts = (asArray(choice?.answers) ?? []).map((answer) => htmlToText(string(answer) ?? '').trim());
// The first option is the correct one; the player shuffles them.
const answers = texts.map((text, index): H5pAnswer => ({ text, correct: index === 0 }));
return [question(htmlToText(string(choice?.question) ?? '').trim(), answers)];
});
}
case 'H5P.Summary': {
return (asArray(params.summaries) ?? []).flatMap((entry) => {
const group = asRecord(entry);
const texts = (asArray(group?.summary) ?? []).map((item) => htmlToText(string(item) ?? '').trim());
const answers = texts.map((text, index): H5pAnswer => ({ text, correct: index === 0 }));
return [question(htmlToText(string(params.intro) ?? '').trim() || 'Welche Aussage stimmt?', answers)];
});
}
case 'H5P.Column': {
// A column stacks sub-contents; each carries its own library.
return (asArray(params.content) ?? []).flatMap((entry) => {
const inner = asRecord(asRecord(entry)?.content);
const innerParams = asRecord(inner?.params);
const innerLibrary = string(inner?.library) ?? 'unknown';
return innerParams ? parseQuestion(innerLibrary, innerParams, unmodelled) : [];
});
}
default: {
const harvested = harvest(params);
if (harvested.length > 0) unmodelled.push(`${name}: ${harvested.join(' | ')}`);
return [];
}
}
}
/** Short names for the libraries worth naming; anything else keeps its own. */
const KIND: Record<string, string> = {
'H5P.MultiChoice': 'multiple choice',
'H5P.TrueFalse': 'true/false',
'H5P.Blanks': 'fill in the blanks',
'H5P.DragText': 'drag the words',
'H5P.MarkTheWords': 'mark the words',
'H5P.SingleChoiceSet': 'single choice',
'H5P.Summary': 'pick the correct statement',
};
/**
* A cloze text: H5P marks the solutions inline as `*answer:tip*`, with
* alternatives separated by slashes.
*/
export function parseCloze(raw: string): { text: string; answers: H5pAnswer[] } {
const answers: H5pAnswer[] = [];
const text = htmlToText(
raw.replace(/\*([^*]+)\*/g, (_, body: string) => {
const [solutions, tip] = body.split(':');
const alternatives = (solutions ?? '').split('/').map((part) => part.trim()).filter(Boolean);
answers.push({
text: alternatives.join(' / '),
correct: true,
...(tip?.trim() ? { tip: tip.trim() } : {}),
});
return `____ (${answers.length})`;
}),
).trim();
return { text, answers };
}
/**
* Every bit of task text in an unmodelled library.
*
* Deliberately blunt — it cannot know which field is the question — but it
* skips the subtrees that hold button labels and display settings, which
* otherwise drown the content in "Überprüfen" and "Wiederholen".
*/
const SKIP_KEYS = new Set([
'UI',
'l10n',
'behaviour',
'overallFeedback',
'confirmCheck',
'confirmRetry',
'media',
'localization',
'a11y',
'accessibility',
'scoreBarLabel',
'texts',
'endGame',
'override',
]);
function harvest(value: unknown, depth = 0, out: string[] = []): string[] {
if (depth > 6 || out.length > 40) return out;
if (typeof value === 'string') {
const text = htmlToText(value).trim();
// Two characters of prose, not a colour code or a library version.
if (text.length > 2 && /\p{L}{2}/u.test(text) && !out.includes(text)) out.push(text);
return out;
}
if (Array.isArray(value)) {
for (const item of value) harvest(item, depth + 1, out);
return out;
}
const record = asRecord(value);
if (record) {
for (const [key, item] of Object.entries(record)) {
if (SKIP_KEYS.has(key)) continue;
harvest(item, depth + 1, out);
}
}
return out;
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
}
function asArray(value: unknown): unknown[] | undefined {
return Array.isArray(value) ? value : undefined;
}
function string(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}

View File

@@ -38,12 +38,51 @@ export interface SubmissionDetail {
submittedFiles: { id: string; name: string }[];
}
/**
* One submission as the *teacher's* grading form holds it.
*
* The teacher view of `/homework/{id}` is a different page from the student's:
* its tabs are `extended` and `submissions` rather than `submission` and
* `feedback`, and the grade lives in the editable form rather than in rendered
* prose. The student parser therefore finds nothing on it, which is why a
* teacher account reported every graded submission as "neither a percentage nor
* feedback was found" while the data was plainly there.
*/
export interface SubmissionGrading {
submissionId: string;
/** Ids from the form's `teamMembers` field — who handed this in. */
submitterIds: string[];
gradeComment?: string;
gradePercent?: number;
}
/** Everything one homework page yields, for whichever role is looking at it. */
export interface HomeworkPage {
/** The account's own submission, when the page is the student view. */
own?: SubmissionDetail;
/** Every submission on the grading form, when the page is the teacher view. */
grading: SubmissionGrading[];
}
export async function fetchHomeworkPage(config: Config, taskId: string): Promise<HomeworkPage | undefined> {
const html = await fetchHomeworkHtml(config, taskId);
if (html === undefined) return undefined;
const own = parseHomeworkPage(html);
const grading = parseTeacherGrading(html);
if (!own && grading.length === 0) return undefined;
return { own, grading };
}
export async function fetchSubmissionDetail(
config: Config,
taskId: string,
): Promise<SubmissionDetail | undefined> {
const html = await fetchHomeworkHtml(config, taskId);
return html === undefined ? undefined : parseHomeworkPage(html);
}
async function fetchHomeworkHtml(config: Config, taskId: string): Promise<string | undefined> {
const url = `${config.baseUrl}/homework/${encodeURIComponent(taskId)}`;
let html: string;
try {
const response = await fetch(url, {
headers: { Cookie: `jwt=${config.jwt}`, Accept: 'text/html' },
@@ -55,12 +94,51 @@ export async function fetchSubmissionDetail(
if (!response.ok || !new URL(response.url).hostname.endsWith(new URL(config.baseUrl).hostname)) {
return undefined;
}
html = await response.text();
return await response.text();
} catch {
return undefined;
}
}
return parseHomeworkPage(html);
/**
* Exported for testing: reads the teacher's grading form.
*
* Anchored on the form's own `name=` attributes rather than on layout, because
* those are what the POST handler reads and so cannot drift without the feature
* itself changing. Each submission contributes one `submissionId` hidden input,
* a `teamMembers` input naming who handed it in, a `grade` number input, and a
* `gradeComment` textarea whose body is HTML-escaped twice over.
*/
export function parseTeacherGrading(html: string): SubmissionGrading[] {
const found: SubmissionGrading[] = [];
const blocks = html.split(/<input name="submissionId"/);
for (const block of blocks.slice(1)) {
const submissionId = /value="([0-9a-f]{24})"/.exec(block)?.[1];
if (!submissionId) continue;
// Only trust fields belonging to this submission: the next block starts
// at the following submissionId input, so cut there first.
const members = /<input name="teamMembers"[^>]*value="([^"]*)"/.exec(block)?.[1] ?? '';
const submitterIds = members
.split(',')
.map((id) => id.trim())
.filter((id) => /^[0-9a-f]{24}$/.test(id));
const entry: SubmissionGrading = { submissionId, submitterIds };
// `value=""` means ungraded; the placeholder is a hint, not a grade.
const gradeValue = /name="grade"[^>]*?value="(\d{1,3})"/.exec(block)?.[1];
if (gradeValue !== undefined) entry.gradePercent = Number(gradeValue);
const commentMarkup = new RegExp(
`<textarea[^>]*data-parent-id="${submissionId}"[^>]*>([\\s\\S]*?)</textarea>`,
).exec(html)?.[1];
const comment = clean(commentMarkup ? decodeEntities(commentMarkup) : undefined);
if (comment) entry.gradeComment = comment;
found.push(entry);
}
return found;
}
/** Exported for testing: the parsing is pure and deserves fixtures, not a network. */

View File

@@ -24,9 +24,27 @@ import { SchulcloudApiError } from './client.ts';
* shared key out from under us. See docs/AUTH.md — the fix is to close the tab,
* not to ping harder.
*/
export interface KeepaliveState {
running: boolean;
/** Seconds of session the instance reported at the last successful extension. */
budgetSeconds: number | undefined;
lastExtendedAt: string | undefined;
/** Set once the instance refused the token; cleared by a restart. */
rejectedAt: string | undefined;
}
export class SessionKeepalive {
private timer: NodeJS.Timeout | undefined;
private stopped = false;
/**
* Bumped by every start and stop. A ping still in flight when the token is
* replaced was sent with the old token, and its 401 must not stop the
* keepalive that is already running with the new one.
*/
private generation = 0;
private budgetSeconds: number | undefined;
private lastExtendedAt: Date | undefined;
private rejectedAt: Date | undefined;
private readonly client: SchulcloudClient;
private readonly intervalMs: number;
/** Retry delay after a failed ping — shorter, to use up the remaining budget. */
@@ -47,27 +65,52 @@ export class SessionKeepalive {
/** Pings once now (validating the token at startup), then on the interval. */
start(): void {
if (this.timer) clearTimeout(this.timer);
this.timer = undefined;
this.stopped = false;
void this.tick();
this.rejectedAt = undefined;
void this.tick(++this.generation);
}
/** Starts over with the token now in use — after a replacement, including one that follows a 401. */
restart(): void {
this.start();
}
stop(): void {
this.stopped = true;
this.generation++;
if (this.timer) clearTimeout(this.timer);
this.timer = undefined;
}
private schedule(delayMs: number): void {
if (this.stopped) return;
this.timer = setTimeout(() => void this.tick(), delayMs);
state(): KeepaliveState {
return {
running: !this.stopped,
budgetSeconds: this.budgetSeconds,
lastExtendedAt: this.lastExtendedAt?.toISOString(),
rejectedAt: this.rejectedAt?.toISOString(),
};
}
private current(generation: number): boolean {
return !this.stopped && generation === this.generation;
}
private schedule(delayMs: number, generation: number): void {
if (!this.current(generation)) return;
this.timer = setTimeout(() => void this.tick(generation), delayMs);
// Never hold the process open just for a keepalive.
this.timer.unref();
}
private async tick(): Promise<void> {
if (this.stopped) return;
private async tick(generation: number): Promise<void> {
if (!this.current(generation)) return;
try {
const { expiresInSeconds } = await this.client.extendSession();
if (!this.current(generation)) return;
this.budgetSeconds = expiresInSeconds;
this.lastExtendedAt = new Date();
// A budget well below the instance's JWT_TIMEOUT_SECONDS means the
// extension is not taking effect — worth seeing in the log, because it
// is the early warning that the session is about to be lost.
@@ -75,8 +118,9 @@ export class SessionKeepalive {
`[schulcloud-mcp] keepalive: session extended, ${expiresInSeconds}s ` +
`(${Math.round(expiresInSeconds / 60)} min) of budget left`,
);
this.schedule(this.intervalMs);
this.schedule(this.intervalMs, generation);
} catch (error) {
if (!this.current(generation)) return;
if (error instanceof SchulcloudApiError && error.isAuthFailure) {
// Past saving: the whitelist entry is gone, or the JWT hit its 30-day
// ceiling. Pinging harder cannot revive it — a human must paste a new
@@ -86,16 +130,18 @@ export class SessionKeepalive {
'If this is ~2h after login, the likely cause is a Schulportal tab left open ' +
'on the same token, whose auto-logout revoked it — close the tab. Otherwise ' +
'the server was down past the 2h window, or the JWT hit its 30-day limit. ' +
'Put a fresh jwt cookie in TSC_JWT_COOKIE and restart. Keepalive stopped.',
'Hand the server a fresh jwt cookie with `schulcloud token set` or on its /token page; ' +
'the keepalive resumes by itself. Keepalive stopped.',
);
this.stop();
this.rejectedAt = new Date();
return;
}
this.log(
`[schulcloud-mcp] keepalive: ping failed (${error instanceof Error ? error.message : String(error)}); ` +
`retrying in ${Math.round(this.retryMs / 1000)}s`,
);
this.schedule(this.retryMs);
this.schedule(this.retryMs, generation);
}
}
}

500
src/core/legacy-files.ts Normal file
View File

@@ -0,0 +1,500 @@
import type { DownloadedFile, SchulcloudClient } from './client.ts';
import { decodeEntities } from './text.ts';
/**
* The "Dateien" file manager — Persönliche Dateien, Kurs-Dateien, Team-Dateien
* and Geteilte Dateien — as one read-only filesystem.
*
* This is the legacy file system, a different store from files-storage
* (`/api/v3/file`), which holds board, topic and task attachments. The two do
* not overlap: asking files-storage for a course's files answers 0 for a course
* whose file manager holds dozens, and on the account this was built for 21 of
* 26 courses keep material here — some teachers use nothing else.
*
* Its Feathers service is not in the public ingress, so it is reached through
* the legacy client: server-rendered listing pages, parsed here, and one JSON
* route for pre-signed downloads (see the client). Two server quirks shape the
* design:
*
* - `GET /files/permittedDirectories/` looks like the obvious JSON source for
* the directory tree, but its query matches course folders on
* `refOwnerModel: 'courses'` while the records say `'course'`. It lists every
* course with **no** folders in any of them. Listings are the only complete
* view, which is also exactly what the file manager itself shows.
* - `GET /files/search/` runs an unindexed regex over every file record and
* times out (504) on the live instance, so finding is done by walking.
*/
export type FileArea = 'my' | 'courses' | 'teams' | 'shared';
interface AreaInfo {
area: FileArea;
label: string;
/** Accepted spellings of the first path segment, compared lower-cased. */
aliases: string[];
summary: string;
}
export const FILE_AREAS: AreaInfo[] = [
{
area: 'my',
label: 'Persönliche Dateien',
aliases: ['my', 'persönliche dateien', 'persoenliche dateien', 'personal', 'meine dateien'],
summary: 'your own files',
},
{
area: 'courses',
label: 'Kurs-Dateien',
aliases: ['courses', 'kurs-dateien', 'meine kurs-dateien', 'kursdateien', 'kurse'],
summary: 'one folder per course, holding what its teachers uploaded',
},
{
area: 'teams',
label: 'Team-Dateien',
aliases: ['teams', 'team-dateien', 'meine team-dateien', 'teamdateien'],
summary: 'one folder per team',
},
{
area: 'shared',
label: 'Geteilte Dateien',
aliases: ['shared', 'geteilte dateien', 'mit mir geteilt'],
summary: 'files other people shared with you, read-only and flat',
},
];
export function areaInfo(area: FileArea): AreaInfo {
return FILE_AREAS.find((entry) => entry.area === area) as AreaInfo;
}
export interface FmDirectory {
id: string;
name: string;
}
export interface FmFile {
id: string;
name: string;
/** Bytes, as the listing reports it. */
size: number;
/** Absent for a blocked file: the page withholds its viewer attributes. */
mimeType?: string;
/** Rejected by the instance's virus scanner; it cannot be downloaded. */
blocked: boolean;
}
export interface FmListing {
directories: FmDirectory[];
files: FmFile[];
}
/** A listing page's markup changed shape; never report that as an empty folder. */
export class FileManagerMarkupError extends Error {}
/**
* Parses one file-manager page. Exported for testing.
*
* Anchored on what the templates (`files/files.hbs`, `files/files-grid.hbs`)
* emit for the page's own scripts — `data-folder-id`, `data-file-id`,
* `data-file-name`, `data-file-size` — rather than on layout classes.
*
* Throws rather than returning an empty listing when the page is not a file
* manager page at all: "0 files" is precisely the wrong answer this module
* exists to fix, and a markup change must not quietly reproduce it.
*/
export function parseFileListing(html: string): FmListing {
if (!/class="route-files"/.test(html)) {
throw new FileManagerMarkupError('the page is not a file-manager listing (markup changed, or not logged in)');
}
const directories: FmDirectory[] = [];
const folderPattern = /<button\b([^>]*\bopenfolder\b[^>]*)>([\s\S]*?)<\/button>/g;
for (const match of html.matchAll(folderPattern)) {
const id = /data-folder-id="([0-9a-f]{24})"/i.exec(match[1] ?? '')?.[1];
if (!id) continue;
// The name is emitted unescaped (`{{{stripOnlyScript name}}}`) inside the
// title element, after an icon; strip the tags, then decode what is left.
const title = /<strong\b[^>]*card-title-directory[^>]*>([\s\S]*?)<\/strong>/.exec(match[2] ?? '')?.[1] ?? '';
const name = decodeEntities(title.replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim();
directories.push({ id, name: name || id });
}
const files: FmFile[] = [];
const cardPattern = /<div\b[^>]*\bclass="card file\b([^"]*)"([^>]*)>/g;
const cards = [...html.matchAll(cardPattern)];
cards.forEach((match, index) => {
const attributes = match[2] ?? '';
const id = /data-file-id="([0-9a-f]{24})"/i.exec(attributes)?.[1];
if (!id) return;
const name = decodeEntities(/data-file-name="([^"]*)"/.exec(attributes)?.[1] ?? '');
const size = Number(/data-file-size="(\d*)"/.exec(attributes)?.[1] ?? '');
// The viewer attributes sit further inside this card; look no further
// than the next card, so one file can never borrow another's type.
const start = (match.index ?? 0) + match[0].length;
const end = cards[index + 1]?.index ?? html.length;
const mimeType = /data-file-viewer-type="([^"]*)"/.exec(html.slice(start, end))?.[1];
files.push({
id,
name: name || id,
size: Number.isFinite(size) ? size : 0,
mimeType: mimeType ? decodeEntities(mimeType) : undefined,
blocked: /\bbtn-file-danger\b/.test(match[1] ?? ''),
});
});
return { directories, files };
}
/**
* Where a directory is. `area` absent is the root; `ownerId` is the course or
* team (absent for `my` and `shared`); `folderId` absent is the owner's top.
*/
export interface DirectoryRef {
area?: FileArea;
ownerId?: string;
folderId?: string;
}
export type FsNode =
| { kind: 'directory'; path: string; ref: DirectoryRef; name: string }
| { kind: 'file'; path: string; parent: DirectoryRef; file: FmFile };
export type FsErrorCode = 'not_found' | 'ambiguous' | 'not_a_directory' | 'not_a_file' | 'not_navigable';
export class FsError extends Error {
readonly code: FsErrorCode;
constructor(code: FsErrorCode, message: string) {
super(message);
this.code = code;
}
}
/** The legacy page that lists a directory. */
export function pageFor(ref: DirectoryRef): string | undefined {
if (!ref.area) return undefined;
if (ref.area === 'shared') return '/files/shared/';
if (ref.area === 'my') return ref.folderId ? `/files/my/${ref.folderId}` : '/files/my/';
if (!ref.ownerId) return `/files/${ref.area}/`;
return ref.folderId ? `/files/${ref.area}/${ref.ownerId}/${ref.folderId}` : `/files/${ref.area}/${ref.ownerId}`;
}
/** The reference for a subdirectory found in `parent`'s listing. */
export function childRef(parent: DirectoryRef, directory: FmDirectory): DirectoryRef {
if (!parent.area) return { area: directory.id as FileArea };
if (parent.area === 'my') return { area: 'my', folderId: directory.id };
if (parent.area === 'shared') {
// The file manager has no route that opens a shared folder — its owner is
// someone else, and every listing route is scoped to an owner. The UI's
// own link to one is a 404.
throw new FsError(
'not_navigable',
`"${directory.name}" is a folder someone shared with you. The file manager cannot open shared folders ` +
'(not even in the browser); ask for the files themselves to be shared, or find them in the owning course.',
);
}
if (!parent.ownerId) return { area: parent.area, ownerId: directory.id };
return { area: parent.area, ownerId: parent.ownerId, folderId: directory.id };
}
/** Splits a path into raw segments. Empty segments (`//`, trailing `/`) drop out. */
export function splitPath(path: string): string[] {
return path
.split('/')
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0);
}
/** Joins names into a display path. Names keep any `/` they contain; see `resolve`. */
export function joinPath(parent: string, name: string): string {
return `${parent === '/' ? '' : parent}/${name}`;
}
function normalise(value: string): string {
return value.normalize('NFC').replace(/\s+/g, ' ').trim().toLocaleLowerCase('de');
}
export interface WalkEntry {
path: string;
/** The listed directory this entry came from; renderers group on it. */
parentPath: string;
depth: number;
parent: DirectoryRef;
directory?: { ref: DirectoryRef; name: string; id: string };
file?: FmFile;
}
export interface WalkResult {
entries: WalkEntry[];
/** Directories that were listed. */
visited: number;
/** Set when the budget ran out before the walk finished. */
truncated: boolean;
failures: { path: string; reason: string }[];
}
interface CachedListing {
at: number;
listing: Promise<FmListing>;
}
/** A listing is reused this long: long enough for ls→read, short enough to stay live. */
const LISTING_TTL_MS = 60_000;
/**
* The file manager as a tree of paths:
*
* / the four areas
* /my/… Persönliche Dateien
* /courses/<course>/… Kurs-Dateien
* /teams/<team>/… Team-Dateien
* /shared/… Geteilte Dateien (flat)
*
* Names are the file manager's own. Because course names contain `/` in real
* data ("LF07 - FIA24A/B - Sb/Ha"), a path is not split naively: resolution
* tries joining consecutive segments into one name and backtracks when a
* shorter reading leads nowhere. Any segment may also be an id instead of a
* name, which is always unambiguous and is what the listings print alongside.
*/
export class FileManager {
private readonly client: SchulcloudClient;
private readonly cache = new Map<string, CachedListing>();
constructor(client: SchulcloudClient) {
this.client = client;
}
/** Lists one directory. The root is synthetic and costs nothing. */
async list(ref: DirectoryRef): Promise<FmListing> {
const page = pageFor(ref);
if (!page) {
return { directories: FILE_AREAS.map((entry) => ({ id: entry.area, name: entry.area })), files: [] };
}
const cached = this.cache.get(page);
if (cached && Date.now() - cached.at < LISTING_TTL_MS) return cached.listing;
const listing = this.client.getFileManagerPage(page).then(parseFileListing);
this.cache.set(page, { at: Date.now(), listing });
// A failure must not be served from the cache for the next minute.
listing.catch(() => this.cache.delete(page));
return listing;
}
/** Resolves a path to a directory or a file. */
async resolve(path: string): Promise<FsNode> {
const segments = splitPath(path);
if (segments.length === 0) return { kind: 'directory', path: '/', ref: {}, name: '/' };
const first = segments[0] as string;
const area = FILE_AREAS.find((entry) => entry.aliases.includes(normalise(first)) || entry.area === first);
if (!area) {
throw new FsError(
'not_found',
`"${first}" is not a file area. The root holds ${FILE_AREAS.map((entry) => `/${entry.area} (${entry.label})`).join(', ')}.`,
);
}
const ref: DirectoryRef = { area: area.area };
const found = await this.descend(ref, `/${area.area}`, segments.slice(1));
return found;
}
private async descend(ref: DirectoryRef, path: string, rest: string[]): Promise<FsNode> {
if (rest.length === 0) return { kind: 'directory', path, ref, name: path.split('/').pop() || '/' };
const listing = await this.list(ref);
const readings = candidateReadings(listing, rest);
if (readings.length === 0) throw notFound(listing, rest[0] as string, path);
let lastError: FsError | undefined;
for (const reading of readings) {
if (reading.matches.length > 1) {
const options = reading.matches.map((match) => `"${match.entry.name}" (\`${match.entry.id}\`)`).join(', ');
throw new FsError(
'ambiguous',
`${path} holds more than one entry named "${reading.name}": ${options}. Use the id as that path segment instead.`,
);
}
const match = reading.matches[0] as { kind: 'directory'; entry: FmDirectory } | { kind: 'file'; entry: FmFile };
const remaining = rest.slice(reading.consumed);
const nextPath = joinPath(path, match.entry.name);
if (match.kind === 'file') {
if (remaining.length === 0) return { kind: 'file', path: nextPath, parent: ref, file: match.entry };
lastError = new FsError('not_a_directory', `${nextPath} is a file, not a folder.`);
continue;
}
try {
return await this.descend(childRef(ref, match.entry), nextPath, remaining);
} catch (error) {
// A shorter reading of a name containing "/" can lead nowhere while a
// longer one resolves; only give up once every reading has failed.
if (error instanceof FsError && error.code !== 'ambiguous' && error.code !== 'not_navigable') {
lastError = error;
continue;
}
throw error;
}
}
throw lastError ?? notFound(listing, rest[0] as string, path);
}
/**
* Walks a directory breadth-first, listing at most `maxDirectories` of them.
*
* Every listing is one page fetch, so the budget is the cost. A folder that
* cannot be read is recorded and skipped, never silently dropped.
*/
async walk(
start: { path: string; ref: DirectoryRef },
options: { maxDepth: number; maxDirectories: number; concurrency?: number },
): Promise<WalkResult> {
const entries: WalkEntry[] = [];
const failures: { path: string; reason: string }[] = [];
let visited = 0;
let truncated = false;
let frontier: { path: string; ref: DirectoryRef; depth: number }[] = [{ ...start, depth: 0 }];
while (frontier.length > 0) {
const next: typeof frontier = [];
const batch = frontier;
frontier = [];
const concurrency = Math.max(1, options.concurrency ?? 3);
for (let i = 0; i < batch.length; i += concurrency) {
const slice = batch.slice(i, i + concurrency);
await Promise.all(
slice.map(async (node) => {
// The root is synthetic and costs no request, so it does not count.
if (pageFor(node.ref)) {
if (visited >= options.maxDirectories) {
truncated = true;
return;
}
visited++;
}
let listing: FmListing;
try {
listing = await this.list(node.ref);
} catch (error) {
failures.push({ path: node.path, reason: error instanceof Error ? error.message : String(error) });
return;
}
// The root's areas keep their defined order; real folders sort by name.
const directories = pageFor(node.ref) ? sortByName(listing.directories) : listing.directories;
for (const directory of directories) {
const path = joinPath(node.path, directory.name);
let ref: DirectoryRef | undefined;
try {
ref = childRef(node.ref, directory);
} catch {
ref = undefined; // shared folders: listed, never opened
}
entries.push({
path,
parentPath: node.path,
depth: node.depth + 1,
parent: node.ref,
directory: { ref: ref ?? node.ref, name: directory.name, id: directory.id },
});
if (ref && node.depth + 1 < options.maxDepth) next.push({ path, ref, depth: node.depth + 1 });
}
for (const file of sortByName(listing.files)) {
entries.push({
path: joinPath(node.path, file.name),
parentPath: node.path,
depth: node.depth + 1,
parent: node.ref,
file,
});
}
}),
);
}
frontier = next;
}
// Unsorted across directories on purpose: sorting whole path strings
// interleaves a folder's children with a sibling that shares its prefix
// ("Sub/…" against "Sub - Kopie"). Renderers group on `parentPath`.
return { entries, visited, truncated, failures };
}
download(file: Pick<FmFile, 'id' | 'name'>): Promise<DownloadedFile> {
return this.client.downloadFileManagerFile(file.id, file.name);
}
}
type Match = { kind: 'directory'; entry: FmDirectory } | { kind: 'file'; entry: FmFile };
/**
* Every way the next path segments can name an entry, shortest first.
*
* `rest[0]`, then `rest[0]/rest[1]`, and so on — so a course called
* "LF07 - FIA24A/B - Sb/Ha" resolves even when typed plainly. An id segment
* matches too. Exact names are preferred over case-insensitive ones.
*/
function candidateReadings(listing: FmListing, rest: string[]): { name: string; consumed: number; matches: Match[] }[] {
const all: Match[] = [
...listing.directories.map((entry) => ({ kind: 'directory' as const, entry })),
...listing.files.map((entry) => ({ kind: 'file' as const, entry })),
];
const first = rest[0] as string;
if (/^[0-9a-f]{24}$/i.test(first)) {
const byId = all.filter((match) => match.entry.id.toLowerCase() === first.toLowerCase());
if (byId.length > 0) return [{ name: first, consumed: 1, matches: byId }];
}
const readings: { name: string; consumed: number; matches: Match[] }[] = [];
for (const strict of [true, false]) {
for (let take = 1; take <= rest.length; take++) {
const name = rest.slice(0, take).join('/');
const matches = all.filter((match) =>
strict ? match.entry.name.trim() === name : normalise(match.entry.name) === normalise(name),
);
if (matches.length > 0 && !readings.some((reading) => reading.consumed === take)) {
readings.push({ name, consumed: take, matches });
}
}
if (readings.length > 0) break;
}
return readings;
}
function notFound(listing: FmListing, segment: string, path: string): FsError {
const names = [...listing.directories.map((entry) => `${entry.name}/`), ...listing.files.map((entry) => entry.name)];
const needle = normalise(segment);
const close = names.filter((name) => normalise(name).includes(needle) || needle.includes(normalise(name).replace(/\/$/, '')));
const hint =
close.length > 0
? ` Did you mean: ${close.slice(0, 5).map((name) => `"${name}"`).join(', ')}?`
: names.length > 0
? ` It holds ${names.length} entr${names.length === 1 ? 'y' : 'ies'}; list it with fs_list.`
: ' It is empty.';
return new FsError('not_found', `No "${segment}" in ${path}.${hint}`);
}
function sortByName<T extends { name: string }>(items: T[]): T[] {
return [...items].sort((a, b) => compareNames(a.name, b.name));
}
/** Name order as a person expects it: German collation, "Blatt 2" before "Blatt 10". */
export function compareNames(a: string, b: string): number {
return a.localeCompare(b, 'de', { numeric: true, sensitivity: 'base' });
}
/**
* Case-insensitive substring; or, when the pattern uses * or ?, a glob that
* must match the whole name, as `find -name` does. Exported for testing.
*/
export function nameMatcher(pattern: string): (name: string) => boolean {
const needle = pattern.normalize('NFC').toLocaleLowerCase('de');
if (!/[*?]/.test(needle)) return (name) => name.normalize('NFC').toLocaleLowerCase('de').includes(needle);
const source = needle
.split('')
.map((char) => (char === '*' ? '.*' : char === '?' ? '.' : char.replace(/[.+^${}()|[\]\\]/g, '\\$&')))
.join('');
const regex = new RegExp(`^${source}$`, 'i');
return (name) => regex.test(name.normalize('NFC'));
}

View File

@@ -1,4 +1,6 @@
import type { Snapshot } from './crawl.ts';
import type { CrawledBoard, Snapshot } from './crawl.ts';
import { h5pSearchText } from './h5p.ts';
import { noteSearchText, noteSections } from './notes.ts';
import { matchesAll, snippet, tokenize } from './text.ts';
/**
@@ -17,10 +19,53 @@ export interface Hit {
where: string;
/** Id to pass to a follow-up tool, with the tool that takes it. */
targetId: string;
targetKind: 'course' | 'board' | 'lesson' | 'task' | 'file';
targetKind: 'course' | 'room' | 'board' | 'lesson' | 'task' | 'file' | 'note';
snippet: string;
}
/**
* Matches a container's boards. Shared by courses and rooms so a hit in a room
* looks and behaves exactly like a hit in a course.
*/
function matchBoards(
boards: CrawledBoard[],
base: { courseId: string; courseTitle: string },
terms: string[],
push: (hit: Hit) => void,
): void {
for (const board of boards) {
if (matchesAll(board.title, terms)) {
push({ ...base, where: 'board title', targetId: board.id, targetKind: 'board', snippet: board.title });
}
// Match per card, so the snippet points at the right part of the board.
for (const column of board.board.columns) {
for (const card of column.cards) {
const parts = [card.title];
for (const element of card.elements) {
if (element.text) parts.push(element.text);
// Pad contents are searched by the index; without this the live
// crawl path would quietly disagree with it.
if (element.padText) parts.push(element.padText);
// Same corpus as the index, or a live search would disagree with it.
if (element.h5p) parts.push(h5pSearchText(element.h5p));
if (element.url) parts.push(element.url);
for (const file of element.files) parts.push(file.name);
}
const haystack = parts.filter(Boolean).join('\n');
if (matchesAll(haystack, terms)) {
push({
...base,
where: `board "${board.title}" → card "${card.title}"`,
targetId: board.id,
targetKind: 'board',
snippet: snippet(haystack, terms),
});
}
}
}
}
}
export function searchSnapshot(snapshot: Snapshot, query: string, limit = 50): Hit[] {
const terms = tokenize(query);
if (terms.length === 0) return [];
@@ -37,32 +82,7 @@ export function searchSnapshot(snapshot: Snapshot, query: string, limit = 50): H
push({ ...base, where: 'course title', targetId: course.course.id, targetKind: 'course', snippet: course.title });
}
for (const board of course.boards) {
if (matchesAll(board.title, terms)) {
push({ ...base, where: 'board title', targetId: board.id, targetKind: 'board', snippet: board.title });
}
// Match per card, so the snippet points at the right part of the board.
for (const column of board.board.columns) {
for (const card of column.cards) {
const parts = [card.title];
for (const element of card.elements) {
if (element.text) parts.push(element.text);
if (element.url) parts.push(element.url);
for (const file of element.files) parts.push(file.name);
}
const haystack = parts.filter(Boolean).join('\n');
if (matchesAll(haystack, terms)) {
push({
...base,
where: `board "${board.title}" → card "${card.title}"`,
targetId: board.id,
targetKind: 'board',
snippet: snippet(haystack, terms),
});
}
}
}
}
matchBoards(course.boards, base, terms, push);
for (const lesson of course.lessons) {
const haystack = `${lesson.name}\n${lesson.text}`;
@@ -85,6 +105,52 @@ export function searchSnapshot(snapshot: Snapshot, query: string, limit = 50): H
}
}
// Rooms carry boards and nothing else, so they reuse the same matcher; a hit
// reads the same whether the board hangs off a course or a room.
for (const room of snapshot.rooms) {
const base = { courseId: room.id, courseTitle: room.name };
if (matchesAll(room.name, terms)) {
push({ ...base, where: 'room title', targetId: room.id, targetKind: 'room', snippet: room.name });
}
matchBoards(room.boards, base, terms, push);
}
// The user's own notes. `courseId` is the subject rather than an id: nothing
// follows a note back to a course, and the subject is what makes the hit
// readable — "Deutsch — my note" rather than a bare path.
for (const note of snapshot.notes) {
// Per lesson where the note has lessons, exactly as the index does it —
// otherwise fresh=true would report "Monday" where the index reports
// "Deutsch, Monday", and the two paths would disagree about the same file.
const sections = noteSections(note);
if (sections.length > 0) {
for (const section of sections) {
const haystack = [section.heading, section.text].filter(Boolean).join('\n');
if (!matchesAll(haystack, terms)) continue;
hits.push({
courseId: note.courseId ?? '',
courseTitle: section.subject ?? note.subject ?? 'Notizen',
where: `my own note, ${section.heading}${note.date ? `, ${note.date}` : ''}`,
targetId: note.path,
targetKind: 'note',
snippet: snippet(haystack, terms),
});
}
continue;
}
const haystack = noteSearchText(note);
if (!matchesAll(haystack, terms)) continue;
hits.push({
courseId: note.courseId ?? '',
courseTitle: note.subject ?? 'Notizen',
where: `my own note${note.date ? `, ${note.date}` : ''}`,
targetId: note.path,
targetKind: 'note',
snippet: snippet(haystack, terms),
});
}
for (const file of snapshot.files) {
if (matchesAll(file.record.name, terms)) {
hits.push({

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

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

View File

@@ -74,7 +74,7 @@ export function safeComponent(raw: string, fallback = 'untitled'): string {
* same card, which the API permits.
*/
export function mirrorPath(at: Breadcrumb, fileName: string, fileId: string): string {
const parts = [at.courseTitle, at.containerTitle, at.cardTitle]
const parts = [at.courseTitle, at.containerTitle, ...(at.folders ?? []), at.cardTitle]
.filter((part): part is string => Boolean(part && part.trim()))
.map((part) => safeComponent(part));

222
src/core/session-token.ts Normal file
View File

@@ -0,0 +1,222 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import type { Config } from '../config.ts';
import { SchulcloudApiError, type SchulcloudClient } from './client.ts';
/**
* The Schulcloud session token, replaceable while the server runs.
*
* A token lives 30 days at most, and a new one can only come from a browser:
* the login is federated single sign-on, so this server cannot mint one
* (docs/AUTH.md). What it can do is take a fresh one without a restart — check
* it against the instance, swap it into the config every request reads it
* from, and keep it in a state file, so a later restart does not fall back to
* the older token still sitting in `.env`.
*
* The token is a credential with read access to the whole account. It goes
* into the state file and nowhere else: not into logs, errors or responses.
*/
/** The claims this server reads. Decoded, never verified — Schulcloud does that. */
export interface TokenClaims {
userId?: string;
/** Seconds since the epoch. */
exp?: number;
}
export type TokenSource = 'environment' | 'state file' | 'replaced at runtime';
export interface TokenStatus {
expiresAt: string | undefined;
/** Whole days until expiry; negative once expired. */
daysLeft: number | undefined;
source: TokenSource;
/** Whether a replacement survives a restart, which needs STATE_DIR. */
persistent: boolean;
}
export type TokenProblem = 'malformed' | 'expired' | 'rejected' | 'other_account';
/** A replacement that was refused, with a message saying what to do instead. */
export class TokenRejected extends Error {
readonly problem: TokenProblem;
constructor(problem: TokenProblem, message: string) {
super(message);
this.name = 'TokenRejected';
this.problem = problem;
}
}
const STATE_FILE = 'schulcloud-jwt';
const DAY_MS = 86_400_000;
/**
* The token inside whatever was pasted.
*
* DevTools copies the bare value, but a cookie line (`jwt=…; Path=/`), quotes
* and a trailing newline all happen on the way from a browser to a terminal,
* and none of them is worth a refused replacement.
*/
export function normalizeToken(input: string): string {
const unquote = (value: string) => value.trim().replace(/^(["'])(.*)\1$/s, '$2').trim();
return unquote(
unquote(input)
.replace(/^jwt\s*=\s*/i, '')
.replace(/;.*$/s, ''),
);
}
export function decodeClaims(token: string): TokenClaims | undefined {
const parts = token.split('.');
if (parts.length !== 3 || parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))) return undefined;
try {
const payload: unknown = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString('utf8'));
if (!payload || typeof payload !== 'object') return undefined;
const { userId, exp } = payload as Record<string, unknown>;
return {
userId: typeof userId === 'string' ? userId : undefined,
exp: typeof exp === 'number' ? exp : undefined,
};
} catch {
return undefined;
}
}
export class SessionToken {
private readonly config: Config;
private readonly client: Pick<SchulcloudClient, 'meAs'>;
private readonly stateFile: string | undefined;
private readonly listeners = new Set<() => void>();
private source: TokenSource = 'environment';
/** One replacement at a time, so two pastes cannot interleave a swap and a write. */
private queue: Promise<unknown> = Promise.resolve();
constructor(config: Config, client: Pick<SchulcloudClient, 'meAs'>, stateDir?: string) {
this.config = config;
this.client = client;
this.stateFile = stateDir ? join(stateDir, STATE_FILE) : undefined;
}
/**
* Picks the token to start with: the saved one when it is the newer of the
* two, since that is what a runtime replacement leaves behind — but never one
* for a different account than `TSC_JWT_COOKIE`, because changing that
* variable is how accounts are switched.
*/
async load(log: (message: string) => void = (message) => console.error(message)): Promise<void> {
if (!this.stateFile) return;
let saved: string;
try {
saved = normalizeToken(await readFile(this.stateFile, 'utf8'));
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') log(`[schulcloud-mcp] session token: could not read the saved one (${code}); using TSC_JWT_COOKIE`);
return;
}
if (!saved || saved === this.config.jwt) return;
const fromState = decodeClaims(saved);
const fromEnvironment = decodeClaims(this.config.jwt);
if (!fromState) {
log('[schulcloud-mcp] session token: the saved one is unreadable; using TSC_JWT_COOKIE');
return;
}
if (fromEnvironment?.userId && fromState.userId !== fromEnvironment.userId) {
log('[schulcloud-mcp] session token: the saved one belongs to another account than TSC_JWT_COOKIE; using TSC_JWT_COOKIE');
return;
}
if ((fromState.exp ?? 0) > (fromEnvironment?.exp ?? 0)) {
this.config.jwt = saved;
this.source = 'state file';
log('[schulcloud-mcp] session token: using the one replaced at runtime, which is newer than TSC_JWT_COOKIE');
}
}
status(): TokenStatus {
const exp = decodeClaims(this.config.jwt)?.exp;
return {
expiresAt: exp === undefined ? undefined : new Date(exp * 1000).toISOString(),
daysLeft: exp === undefined ? undefined : Math.floor((exp * 1000 - Date.now()) / DAY_MS),
source: this.source,
persistent: this.stateFile !== undefined,
};
}
/** Called after every successful replacement — the keepalive restarts on it. */
onReplaced(listener: () => void): void {
this.listeners.add(listener);
}
replace(input: string): Promise<{ changed: boolean; persisted: boolean; status: TokenStatus }> {
const run = this.queue.then(() => this.swap(input));
this.queue = run.catch(() => {});
return run;
}
private async swap(input: string): Promise<{ changed: boolean; persisted: boolean; status: TokenStatus }> {
const token = normalizeToken(input);
const claims = decodeClaims(token);
if (!claims) {
throw new TokenRejected(
'malformed',
'That is not a jwt cookie value: it should be three parts separated by dots, starting with "eyJ". ' +
'Copy the Value column of the cookie named "jwt".',
);
}
if (claims.exp !== undefined && claims.exp * 1000 <= Date.now()) {
throw new TokenRejected(
'expired',
`That token expired on ${new Date(claims.exp * 1000).toISOString().slice(0, 10)}. Log in again and copy the new cookie.`,
);
}
if (token === this.config.jwt) return { changed: false, persisted: false, status: this.status() };
let userId: string;
try {
userId = (await this.client.meAs(token)).user.id;
} catch (error) {
if (error instanceof SchulcloudApiError && error.isAuthFailure) {
throw new TokenRejected(
'rejected',
'Schulcloud rejected that token (401): its session has already ended. Log in again in a private ' +
'window and copy the cookie — then close the window, because left open it logs the token out ' +
'about two hours after login.',
);
}
throw error;
}
const current = decodeClaims(this.config.jwt)?.userId;
if (current && userId !== current) {
throw new TokenRejected(
'other_account',
'That token belongs to a different Schulcloud account than the one this server reads. ' +
'To switch accounts, change TSC_JWT_COOKIE and restart the server.',
);
}
this.config.jwt = token;
this.source = 'replaced at runtime';
const persisted = await this.persist(token);
for (const listener of this.listeners) listener();
return { changed: true, persisted, status: this.status() };
}
/** Written beside itself and renamed into place, so a crash cannot leave half a token. */
private async persist(token: string): Promise<boolean> {
if (!this.stateFile) return false;
try {
await mkdir(dirname(this.stateFile), { recursive: true, mode: 0o700 });
const temporary = `${this.stateFile}.${process.pid}.tmp`;
await writeFile(temporary, `${token}\n`, { mode: 0o600 });
await rename(temporary, this.stateFile);
return true;
} catch (error) {
console.error(
`[schulcloud-mcp] session token: replaced, but not saved (${(error as NodeJS.ErrnoException).code ?? 'error'}); ` +
'a restart will fall back to TSC_JWT_COOKIE',
);
return false;
}
}
}

View File

@@ -57,8 +57,24 @@ export function htmlToText(html: string | undefined | null): string {
if (!html) return '';
return decodeEntities(
html
// A newline in HTML source is just whitespace; only tags make lines.
// Flattening first is what stops `<br>` followed by a newline — the
// shape every server-side template produces — from reading as a
// paragraph break, and it takes the template's indentation with it.
.replace(/\s*\n\s*/g, ' ')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|h[1-6]|li|tr)>/gi, '\n')
// A cell whose text is wrapped in its own <p> — which is what the
// editor produces — would otherwise break its row in half.
.replace(/<(td|th)([^>]*)>\s*<p[^>]*>/gi, '<$1$2>')
.replace(/<\/p>\s*<\/(td|th)>/gi, '</$1>')
// Cells before rows: a table flattened without cell separators runs
// its columns together, which is how a two-column worksheet grid came
// out as a meaningless list of fragments.
.replace(/<\/(td|th)>/gi, ' | ')
// Paragraphs and headings read as paragraphs; list items and table
// rows are single lines.
.replace(/<\/(p|h[1-6])>/gi, '\n\n')
.replace(/<\/(div|li|tr)>/gi, '\n')
.replace(/<li[^>]*>/gi, '- ')
// Keep the href when the anchor text does not already contain it.
.replace(/<a\b[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, (_, href: string, label: string) => {
@@ -68,7 +84,14 @@ export function htmlToText(html: string | undefined | null): string {
})
.replace(/<[^>]+>/g, ''),
)
.replace(/[ \t]+\n/g, '\n')
// Source HTML is pretty-printed, so nearly every line arrives with the
// template's indentation still attached. Collapsing runs of spaces and
// trimming each line is what HTML rendering would have done anyway, and
// without it a submission reads as prose adrift in whitespace.
.replace(/[^\S\n]+/g, ' ')
.split('\n')
.map((line) => line.trim().replace(/\s*\|\s*$/, ''))
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}

63
src/core/totp.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* RFC 6238 one-time codes, for the WebUntis mobile API.
*
* WebUntis authenticates the mobile app not with a password but with a static
* base32 key (the one behind the QR code in Profil → Freigaben) from which each
* request derives a fresh code. That makes the credential usable by an
* always-on server without a session to hold open — see core/untis.ts.
*
* Implemented here rather than pulled in: it is HMAC-SHA1 plus a truncation,
* `node:crypto` has the hard part, and a dependency that handles a credential
* is a dependency worth not having.
*/
import { createHmac } from 'node:crypto';
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
/**
* Decodes base32 (RFC 4648) as written on a QR code: padding and lowercase are
* accepted, anything outside the alphabet is an error rather than a silently
* wrong key.
*/
export function base32Decode(value: string): Buffer {
const clean = value.replace(/[=\s]/g, '').toUpperCase();
if (clean.length === 0) throw new Error('base32 value is empty');
const bytes: number[] = [];
let buffer = 0;
let bits = 0;
for (const char of clean) {
const index = BASE32_ALPHABET.indexOf(char);
if (index === -1) throw new Error('base32 value contains a character outside A-Z and 2-7');
buffer = (buffer << 5) | index;
bits += 5;
if (bits >= 8) {
bytes.push((buffer >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Buffer.from(bytes);
}
/**
* The current 6-digit code for a base32 secret, as a **zero-padded string**.
*
* A string on purpose: one code in ten starts with a zero, and sending it as a
* JSON number drops that digit. WebUntis accepts either shape, so the string is
* the one that is always right.
*/
export function totp(secret: string, at: number = Date.now(), stepSeconds = 30, digits = 6): string {
const counter = Math.floor(at / 1000 / stepSeconds);
const message = Buffer.alloc(8);
// Counter is 64-bit big-endian; Node has no writeUInt64BE.
message.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
message.writeUInt32BE(counter >>> 0, 4);
const digest = createHmac('sha1', base32Decode(secret)).update(message).digest();
// RFC 6238 dynamic truncation: the low nibble of the last byte picks the
// 4-byte window, whose top bit is masked off to keep it positive.
const offset = digest[digest.length - 1]! & 0x0f;
const binary =
((digest[offset]! & 0x7f) << 24) | (digest[offset + 1]! << 16) | (digest[offset + 2]! << 8) | digest[offset + 3]!;
return String(binary % 10 ** digits).padStart(digits, '0');
}

View File

@@ -239,6 +239,86 @@ export type FileParentType =
| 'boardnodes'
| 'externaltools';
/**
* A room ("Raum") — the newer collaboration space, separate from courses.
*
* The naming is a trap worth knowing: the sidebar's *Kurse* entry points at
* `/rooms/courses-overview` and shows courses, while *Räume* points at `/rooms`
* and shows these. A url containing `/rooms` says nothing about which one it is.
*
* Unlike a course, a room holds only boards — no lessons, no tasks.
*/
export interface RoomItem {
id: string;
name: string;
color?: string;
schoolId?: string;
startDate?: string;
endDate?: string;
createdAt?: string;
updatedAt?: string;
/**
* What this account may do here.
*
* An object keyed by operation, not a list of granted ones: every operation
* is present and false means denied. Typing it as `string[]` type-checked
* fine and threw `.some is not a function` the moment anything read it.
*/
allowedOperations?: Record<string, boolean>;
isLocked?: boolean;
totalMembers?: number;
}
export interface RoomDetails extends Omit<RoomItem, 'isLocked' | 'totalMembers'> {
features?: string[];
}
/**
* A board inside a room.
*
* Carries `isVisible`, which the course-page projection does not — so unlike a
* course board, a room's draft boards can be told apart before trying to open
* one and getting a 403.
*/
export interface RoomBoardItem {
id: string;
title: string;
layout?: string;
isVisible?: boolean;
createdAt?: string;
updatedAt?: string;
allowedOperations?: string[];
}
export interface RoomMember {
userId: string;
firstName?: string;
lastName?: string;
/** roomowner | roomadmin | roomeditor | roomviewer */
roomRoleName?: string;
schoolRoleNames?: string[];
schoolName?: string;
}
/** Someone who has asked to join a room and is waiting for an admin. */
export interface RoomApplicant {
userId?: string;
firstName?: string;
lastName?: string;
schoolName?: string;
requestedAt?: string;
}
/** A shareable link into a room. Visible only to accounts that may manage them. */
export interface RoomInvitationLink {
id: string;
title?: string;
activeUntil?: string;
isOnlyForTeachers?: boolean;
restrictedToCreatorSchool?: boolean;
requiresConfirmation?: boolean;
}
export const FILE_PARENT_TYPES: FileParentType[] = [
'users',
'schools',
@@ -294,3 +374,84 @@ export interface NewsResponse {
creator?: { id: string; firstName?: string; lastName?: string };
createdAt?: string;
}
/**
* A course as the legacy `/api/v1/courses` service returns it.
*
* The v3 projection (`CourseMetadataResponse`) carries only id, title, colour
* and dates — no description, no teachers, no members, no timetable. All of
* that still exists, and `/api/v1/courses` is one of exactly three legacy
* routes the deployment's own ingress table still publishes
* (`dof_app_deploy/ansible/group_vars/all/x_ingress.yml`: courses, users,
* classes), so this is production surface rather than a leftover.
*/
export interface LegacyCourse {
_id?: string;
id?: string;
name?: string;
description?: string;
color?: string;
startDate?: string;
untilDate?: string;
isArchived?: boolean;
teacherIds?: string[];
substitutionIds?: string[];
userIds?: string[];
classIds?: string[];
/** The weekly timetable: one entry per recurring slot. */
times?: CourseTime[];
}
/** One recurring slot of a course's weekly timetable. */
export interface CourseTime {
/** 0 = Monday, as the legacy client renders it. */
weekday?: number;
/** Milliseconds since midnight. */
startTime?: number;
/** Milliseconds. */
duration?: number;
room?: string;
}
/** A user as `/api/v1/users/{id}` returns it — the only way to turn an id into a name. */
export interface LegacyUser {
_id?: string;
id?: string;
firstName?: string;
lastName?: string;
fullName?: string;
displayName?: string;
}
/** A class ("Klasse") from `/api/v3/groups/class`. */
export interface ClassItem {
id: string;
name?: string;
type?: string;
teacherNames?: string[];
studentCount?: number;
isUpgradable?: boolean;
}
/** A group from `/api/v3/groups` — room membership groups, classes, courses. */
export interface GroupItem {
id: string;
name?: string;
type?: string;
organizationId?: string;
users?: { id: string; firstName?: string; lastName?: string; role?: string }[];
}
/** `GET /api/v3/file/stats/{parentType}/{parentId}`. */
export interface ParentFileStats {
fileCount: number;
totalSizeInBytes: number;
}
/**
* Widths the preview endpoint accepts.
*
* An enum rather than a free number — `width=1600` is rejected as a validation
* error that names the value but not the permitted set.
*/
export type PreviewWidth = 50 | 150 | 500;

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

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

555
src/core/untis.ts Normal file
View File

@@ -0,0 +1,555 @@
/**
* WebUntis: the timetable, its changes, homework and what was taught.
*
* The school keeps its timetable in WebUntis, not in Schulcloud — Schulcloud's
* course `times` are empty here — so "what do I have today, and has anything
* been cancelled" is a question only this API can answer. It is the other half
* of a school day: Schulcloud holds the material, WebUntis holds the schedule.
*
* **Read-only, but not by the Schulcloud client's rule.** This is JSON-RPC:
* every call is a POST, reads included, so "GET only" cannot be the guarantee.
* `READ_METHODS` is: `call` refuses any method outside it. That matters because
* the key is the mobile app's credential and can do what the app can — this
* account holds `W_OWN_ABSENCE`, so the same key could report the user absent.
*
* Authentication is a one-time code derived from the base32 key behind the QR
* code in WebUntis → Profil → Freigaben (see core/totp.ts). Each request signs
* itself, so unlike the Schulcloud session there is nothing to hold open and
* nothing to refresh — but the server's clock has to be right, which is what
* error -8524 means.
*/
import { compactDate } from './dates.ts';
import { totp } from './totp.ts';
export interface UntisConfig {
/** Bare host from the QR dialog's "Url" field, e.g. `ags-erfurt.webuntis.com`. */
server: string;
/** The school's login name, e.g. `ags-erfurt`. */
school: string;
user: string;
/** The base32 key from the QR dialog. A credential: never log it. */
secret: string;
}
/**
* The methods this client may call. Everything here reads; nothing writes.
*
* Verified against the live instance — `getClassregEvents2017` and
* `getSchoolyears2017` answer "Method not found" and are deliberately absent.
*/
const READ_METHODS = new Set([
'getUserData2017',
'getTimetable2017',
'getLessonTopic2017',
'getHomeWork2017',
'getMessagesOfDay2017',
]);
/**
* The read-only guarantee for this API, at its single choke point.
*
* Exported so a test can hold it to it: this is the line that keeps a key which
* *can* write from being used to write.
*/
export function assertReadMethod(method: string): void {
if (!READ_METHODS.has(method)) {
throw new Error(`Refusing to call WebUntis method ${method}: not in the read-only allowlist.`);
}
}
/**
* The client version the mobile API expects as `?v=`.
*
* Not optional: `jsonrpc_intern.do` reads the parameter without checking it,
* so omitting it fails with a Java NullPointerException reported as -8998.
*/
const API_VERSION = 'i3.2';
/** How long master data (subjects, teachers, rooms, holidays) is reused. */
const MASTER_DATA_TTL_MS = 6 * 60 * 60_000;
/** A JSON-RPC error from WebUntis. They arrive with HTTP 200 and an `error` body. */
export class UntisApiError extends Error {
readonly code: number;
readonly method: string;
constructor(code: number, method: string, message: string) {
super(`WebUntis ${method} failed (${code}): ${message}`);
this.name = 'UntisApiError';
this.code = code;
this.method = method;
}
/** -8504: the key or user name is wrong, or the key has been regenerated. */
get isAuthFailure(): boolean {
return this.code === -8504;
}
/** -8524: our clock is too far from the school server's. */
get isClockSkew(): boolean {
return this.code === -8524;
}
}
// --- what callers get ----------------------------------------------------
/** A subject, teacher, room or class: a short code plus, where known, a full name. */
export interface UntisName {
name: string;
longName?: string;
}
export interface UntisHomework {
id: number;
lessonId: number;
/** When it was set, `YYYY-MM-DD`. */
assigned: string;
/** When it is due, `YYYY-MM-DD`. */
due: string;
text: string;
remark?: string;
completed: boolean;
subject?: UntisName;
attachments: number;
}
export interface UntisLesson {
/** The period id, which `getLessonTopic2017` takes. */
periodId: number;
/** The lesson (series) id: the same weekly slot shares it. */
lessonId: number;
date: string;
/** `HH:MM` in the school's local time. */
start: string;
end: string;
/** Raw status words, e.g. `REGULAR`, `CANCELLED`, `IRREGULAR`. */
statuses: string[];
cancelled: boolean;
/** A substitution, a moved lesson or anything else Untis calls irregular. */
changed: boolean;
subjects: UntisName[];
teachers: UntisName[];
rooms: UntisName[];
classes: UntisName[];
/** What each kind of element replaced, when Untis says so (its `orgId`). */
replaced: { subjects: UntisName[]; teachers: UntisName[]; rooms: UntisName[] };
/** The three free-text fields a teacher can attach to a period. */
notes: { lesson?: string; substitution?: string; info?: string };
homework: UntisHomework[];
/** The exam module's title, when the school uses it. */
exam?: string;
online: boolean;
}
export interface UntisHoliday {
name: string;
longName: string;
start: string;
end: string;
}
export interface UntisDay {
date: string;
lessons: UntisLesson[];
/** Holidays and single free days covering this date. */
holidays: UntisHoliday[];
}
export interface UntisTimetable {
from: string;
to: string;
/** Every date in the range, including the ones without lessons. */
days: UntisDay[];
}
export interface UntisIdentity {
displayName: string;
elementId: number;
elementType: string;
schoolName: string;
/** Untis' own permission words, e.g. `R_MY_ABSENCES`, `W_OWN_ABSENCE`. */
rights: string[];
}
/** One class-register entry: what was taught in a lesson of this series. */
export interface UntisTopic {
text: string;
periodId: number;
date: string;
start: string;
end: string;
}
export interface UntisMessage {
subject: string;
text: string;
}
// --- raw shapes ----------------------------------------------------------
interface RawElement {
type: string;
id: number;
orgId?: number;
}
interface RawPeriod {
id: number;
lessonId: number;
startDateTime: string;
endDateTime: string;
text?: { lesson?: string; substitution?: string; info?: string; attachments?: unknown[] };
elements?: RawElement[];
is?: string[];
homeWorks?: RawHomework[];
exam?: { name?: string; text?: string } | null;
isOnlinePeriod?: boolean;
}
interface RawHomework {
id: number;
lessonId: number;
startDate: string;
endDate: string;
text: string;
remark?: string | null;
completed?: boolean;
attachments?: unknown[];
}
interface RawNamed {
id: number;
name: string;
longName?: string;
firstName?: string;
lastName?: string;
}
interface RawMasterData {
timeStamp?: number;
subjects?: RawNamed[];
teachers?: RawNamed[];
rooms?: RawNamed[];
klassen?: RawNamed[];
holidays?: { name: string; longName: string; startDate: string; endDate: string }[];
}
interface RawUserData {
userData?: {
displayName?: string;
elemId?: number;
elemType?: string;
schoolName?: string;
rights?: string[];
};
masterData?: RawMasterData;
}
interface RawTimetable {
timetable?: { periods?: RawPeriod[] };
masterData?: RawMasterData;
}
// --- client --------------------------------------------------------------
export class UntisClient {
private readonly config: UntisConfig;
private readonly timeoutMs: number;
private identityPromise: Promise<UntisIdentity> | undefined;
private masterData: { data: RawMasterData; at: number } | undefined;
constructor(config: UntisConfig, timeoutMs = 30_000) {
this.config = config;
this.timeoutMs = timeoutMs;
}
/** For status lines: where this client is pointed, never how it authenticates. */
get origin(): string {
return `${this.config.server}/${this.config.school}`;
}
private async call<T>(method: string, params: Record<string, unknown>): Promise<T> {
// The allowlist is the read-only guarantee for this API; widening it is a
// deliberate act, not something a caller can do by passing a string.
assertReadMethod(method);
const url =
`https://${this.config.server}/WebUntis/jsonrpc_intern.do` +
`?m=${encodeURIComponent(method)}&school=${encodeURIComponent(this.config.school)}&v=${API_VERSION}`;
const response = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
// Honest, and accepted: the endpoint does not check for the app's own
// user agent.
'user-agent': 'schulcloud-mcp',
},
body: JSON.stringify({
id: 'schulcloud-mcp',
jsonrpc: '2.0',
method,
params: [
{
...params,
// The code is a string: one in ten starts with a zero, which a
// JSON number would drop.
auth: { user: this.config.user, otp: totp(this.config.secret), clientTime: Date.now() },
},
],
}),
signal: AbortSignal.timeout(this.timeoutMs),
});
const body = (await response.json().catch(() => undefined)) as
| { result?: T; error?: { code?: number; message?: string } }
| undefined;
// Failures come back as HTTP 200 with an `error` member, so the body is
// the thing to check first.
if (body?.error) {
throw new UntisApiError(body.error.code ?? 0, method, body.error.message ?? 'no message');
}
if (!response.ok) throw new UntisApiError(0, method, `HTTP ${response.status}`);
if (body?.result === undefined) throw new UntisApiError(0, method, 'response carried no result');
return body.result;
}
/** Who the key belongs to. Cached; a failure is not, so a fixed key recovers. */
identity(): Promise<UntisIdentity> {
this.identityPromise ??= this.call<RawUserData>('getUserData2017', {})
.then((raw) => {
if (raw.masterData) this.masterData = { data: raw.masterData, at: Date.now() };
const user = raw.userData ?? {};
if (user.elemId === undefined || !user.elemType) {
throw new UntisApiError(0, 'getUserData2017', 'response carried no user element');
}
return {
displayName: user.displayName ?? '(unnamed)',
elementId: user.elemId,
elementType: user.elemType,
schoolName: user.schoolName ?? this.config.school,
rights: user.rights ?? [],
};
})
.catch((error: unknown) => {
this.identityPromise = undefined;
throw error;
});
return this.identityPromise;
}
/**
* The timetable for a date range, with every day in it — including the ones
* with no lessons, because "no school today" is an answer and an empty list
* is not.
*/
async timetable(from: string, to: string): Promise<UntisTimetable> {
const me = await this.identity();
const raw = await this.call<RawTimetable>('getTimetable2017', {
id: me.elementId,
type: me.elementType,
startDate: compactDate(from),
endDate: compactDate(to),
// Always ask for full master data rather than a delta against a cached
// timestamp: the delta's removal semantics are unverified, and the whole
// set is one payload of a few hundred kilobytes a handful of times a day.
masterDataTimestamp: 0,
timetableTimestamp: 0,
timetableTimestamps: [],
});
if (raw.masterData?.subjects) this.masterData = { data: raw.masterData, at: Date.now() };
const master = raw.masterData ?? (await this.master());
const lessons = (raw.timetable?.periods ?? []).map((period) => this.toLesson(period, master));
const days: UntisDay[] = [];
for (let date = from; date <= to; date = nextDate(date)) {
days.push({
date,
lessons: lessons.filter((lesson) => lesson.date === date).sort(byStart),
holidays: holidaysOn(master, date),
});
}
return { from, to, days };
}
/**
* Homework set for a date range.
*
* The range filters by the homework's own dates, not by when it was set, so
* a window that ends today shows nothing that is due tomorrow.
*/
async homework(from: string, to: string): Promise<UntisHomework[]> {
const me = await this.identity();
const raw = await this.call<{
homeWorks?: RawHomework[];
lessonsById?: Record<string, { subjectId?: number }>;
}>('getHomeWork2017', {
id: me.elementId,
type: me.elementType,
startDate: compactDate(from),
endDate: compactDate(to),
});
const master = await this.master();
const subjects = index(master.subjects);
return (raw.homeWorks ?? [])
.map((item) => {
const subjectId = raw.lessonsById?.[String(item.lessonId)]?.subjectId;
return toHomework(item, subjectId === undefined ? undefined : named(subjects.get(subjectId)));
})
.sort((a, b) => a.due.localeCompare(b.due));
}
/**
* What was taught in the previous lessons of a period's series — the class
* register's "Unterrichtsinhalt", newest first.
*
* The parameter is a single `periodId`; a list is rejected as "period 0 not
* found".
*/
async lessonTopics(periodId: number): Promise<UntisTopic[]> {
const raw = await this.call<{
previousTopics?: { text?: string; periodId?: number; startDateTime?: string; endDateTime?: string }[];
}>('getLessonTopic2017', { periodId });
return (raw.previousTopics ?? [])
.filter((topic) => topic.text?.trim())
.map((topic) => {
const start = splitLocal(topic.startDateTime ?? '');
const end = splitLocal(topic.endDateTime ?? '');
return {
text: topic.text!.trim(),
periodId: topic.periodId ?? periodId,
date: start.date,
start: start.time,
end: end.time,
};
});
}
/** The school's "Nachrichten des Tages" for one date. Often empty. */
async messagesOfDay(date: string): Promise<UntisMessage[]> {
const raw = await this.call<{ messages?: { subject?: string; text?: string }[] }>('getMessagesOfDay2017', {
date: compactDate(date),
});
return (raw.messages ?? []).map((message) => ({
subject: message.subject?.trim() ?? '',
text: message.text?.trim() ?? '',
}));
}
/** Master data, refreshed at most every few hours: it changes with the school year. */
private async master(): Promise<RawMasterData> {
if (this.masterData && Date.now() - this.masterData.at < MASTER_DATA_TTL_MS) return this.masterData.data;
const raw = await this.call<RawUserData>('getUserData2017', {});
const data = raw.masterData ?? {};
this.masterData = { data, at: Date.now() };
return data;
}
private toLesson(period: RawPeriod, master: RawMasterData): UntisLesson {
const start = splitLocal(period.startDateTime);
const end = splitLocal(period.endDateTime);
const maps = {
SUBJECT: index(master.subjects),
TEACHER: index(master.teachers),
ROOM: index(master.rooms),
CLASS: index(master.klassen),
};
const of = (type: keyof typeof maps): RawElement[] => (period.elements ?? []).filter((e) => e.type === type);
const resolve = (type: keyof typeof maps): UntisName[] =>
of(type).map((element) => named(maps[type].get(element.id)) ?? { name: `${type.toLowerCase()} #${element.id}` });
// Untis expresses "X instead of Y" by keeping the original in orgId.
const replacedBy = (type: keyof typeof maps): UntisName[] =>
of(type)
.filter((element) => element.orgId !== undefined && element.orgId !== element.id)
.map((element) => named(maps[type].get(element.orgId!)) ?? { name: `${type.toLowerCase()} #${element.orgId}` });
const statuses = period.is ?? [];
const note = (value: string | undefined): string | undefined => value?.trim() || undefined;
return {
periodId: period.id,
lessonId: period.lessonId,
date: start.date,
start: start.time,
end: end.time,
statuses,
cancelled: statuses.includes('CANCELLED'),
changed: statuses.includes('IRREGULAR') || statuses.includes('SUBSTITUTION'),
subjects: resolve('SUBJECT'),
teachers: resolve('TEACHER'),
rooms: resolve('ROOM'),
classes: resolve('CLASS'),
replaced: { subjects: replacedBy('SUBJECT'), teachers: replacedBy('TEACHER'), rooms: replacedBy('ROOM') },
notes: {
lesson: note(period.text?.lesson),
substitution: note(period.text?.substitution),
info: note(period.text?.info),
},
homework: (period.homeWorks ?? []).map((item) => toHomework(item, undefined)),
exam: note(period.exam?.name) ?? note(period.exam?.text),
online: period.isOnlinePeriod === true,
};
}
}
// --- helpers -------------------------------------------------------------
/**
* Splits `2026-09-21T11:45Z` into date and time — **as local school time**.
*
* The `Z` is a lie: the school's time grid starts lessons at 08:00 and the API
* reports exactly `08:00Z` for them. Parsing these as UTC would shift every
* lesson by an hour or two, so the string is taken apart rather than given to
* `new Date`.
*/
export function splitLocal(value: string): { date: string; time: string } {
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(value);
if (!match) {
// Dropping an unparsable lesson would silently shorten a school day; a
// changed format has to be visible.
throw new Error(`WebUntis returned a timestamp in an unexpected format: ${value}`);
}
return { date: match[1]!, time: match[2]! };
}
function index(list: RawNamed[] | undefined): Map<number, RawNamed> {
return new Map((list ?? []).map((entry) => [entry.id, entry]));
}
function named(entry: RawNamed | undefined): UntisName | undefined {
if (!entry) return undefined;
const full = [entry.firstName, entry.lastName].filter(Boolean).join(' ').trim();
const longName = entry.longName?.trim() || full || undefined;
return { name: entry.name, ...(longName ? { longName } : {}) };
}
function toHomework(item: RawHomework, subject: UntisName | undefined): UntisHomework {
return {
id: item.id,
lessonId: item.lessonId,
assigned: item.startDate,
due: item.endDate,
text: item.text?.trim() ?? '',
...(item.remark?.trim() ? { remark: item.remark.trim() } : {}),
completed: item.completed === true,
...(subject ? { subject } : {}),
attachments: item.attachments?.length ?? 0,
};
}
function holidaysOn(master: RawMasterData, date: string): UntisHoliday[] {
return (master.holidays ?? [])
.filter((holiday) => holiday.startDate <= date && date <= holiday.endDate)
.map((holiday) => ({
name: holiday.name.trim(),
longName: holiday.longName.trim(),
start: holiday.startDate,
end: holiday.endDate,
}));
}
function nextDate(date: string): string {
return new Date(Date.parse(`${date}T12:00:00Z`) + 86_400_000).toISOString().slice(0, 10);
}
function byStart(a: UntisLesson, b: UntisLesson): number {
return a.start.localeCompare(b.start) || a.periodId - b.periodId;
}

View File

@@ -2,7 +2,30 @@ import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { Readable } from 'node:stream';
import express, { type Request, type Response, type Router } from 'express';
import { SchulcloudApiError } from '../core/client.ts';
import {
compareNames,
FileManagerMarkupError,
FsError,
nameMatcher,
type FmFile,
type FsErrorCode,
type WalkEntry,
} from '../core/legacy-files.ts';
import { dayLessons, dayNoteSkeleton, dayNoteTitle, missingHeadings } from '../core/day-note.ts';
import { isCalendarDate, schoolToday } from '../core/dates.ts';
import {
dayNotePath,
NoteConflict,
NoteNotFound,
filterNotes,
readNoteAt,
readNotes,
replaceNote,
writeNote,
} from '../core/notes.ts';
import { resolveWithin } from '../core/paths.ts';
import { TokenRejected } from '../core/session-token.ts';
import type { Services } from '../services.ts';
/**
@@ -14,8 +37,12 @@ import type { Services } from '../services.ts';
* from the mirror does neither, which matters for the video files.
*
* Nothing here can write to Schulcloud. `/refresh` writes only to the Pi's own
* index and mirror, and every upstream call it triggers is a GET.
* 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();
@@ -52,9 +79,22 @@ export function createApiRouter(services: Services): Router {
router.post('/refresh', express.json({ limit: '16kb' }), async (req: Request, res: Response) => {
if (!services.indexer) return res.status(503).json({ error: 'no_index' });
const body = (req.body ?? {}) as { courseId?: string; force?: boolean };
const body = (req.body ?? {}) as { courseId?: string; force?: boolean; wait?: boolean };
try {
const result = await services.indexer.refresh(body.courseId ?? 'full', { force: body.force === true });
const scope = body.courseId ?? 'full';
// `wait: false` answers at once and leaves the caller to poll /status.
// Waiting for the result in this request is kept for older clients, but
// it cannot outlast a long crawl: Node's fetch abandons a response whose
// headers have not arrived within five minutes.
if (body.wait === false) {
const { run, joined } = services.indexer.start(scope, { force: body.force === true });
// The outcome is recorded by the indexer (status().lastResult/lastError);
// this only keeps an unawaited failure from becoming an unhandled one.
run.catch(() => {});
const status = services.indexer.status();
return res.status(202).json({ started: !joined, joined, scope, startedAt: status.startedAt ?? null });
}
const result = await services.indexer.refresh(scope, { force: body.force === true });
return res.json(result);
} catch (error) {
// A rate-limit refusal is the caller's problem to act on, not a fault.
@@ -64,6 +104,113 @@ export function createApiRouter(services: Services): Router {
}
});
// --- the file manager ("Dateien"), as a filesystem ----------------------
//
// Live, not from the index: these answer what the file manager holds now, and
// need no database. Paths are the same ones the MCP fs_* tools print.
router.get('/fs/list', async (req: Request, res: Response) => {
try {
const node = await services.files.resolve(stringParam(req.query.path) ?? '/');
if (node.kind === 'file') return res.json({ path: node.path, kind: 'file', file: node.file });
const listing = await services.files.list(node.ref);
return res.json({
path: node.path,
kind: 'directory',
area: node.ref.area ?? null,
directories: listing.directories.map((entry) => ({ ...entry, path: childPath(node.path, entry.name) })),
files: listing.files.map((entry) => ({ ...entry, path: childPath(node.path, entry.name) })),
});
} catch (error) {
return fsFail(res, error, 'fs list');
}
});
router.get('/fs/tree', async (req: Request, res: Response) => {
try {
const node = await services.files.resolve(stringParam(req.query.path) ?? '/');
if (node.kind === 'file') return res.json({ path: node.path, kind: 'file', file: node.file });
const result = await services.files.walk(node, {
maxDepth: boundedInt(req.query.depth, 3, 1, 12),
maxDirectories: boundedInt(req.query.maxFolders, 200, 1, 1000),
});
return res.json({
path: node.path,
kind: 'directory',
entries: result.entries.map(treeEntry),
visited: result.visited,
truncated: result.truncated,
failures: result.failures,
});
} catch (error) {
return fsFail(res, error, 'fs tree');
}
});
router.get('/fs/find', async (req: Request, res: Response) => {
const name = stringParam(req.query.name);
if (!name) return res.status(400).json({ error: 'bad_request', message: 'Give name.' });
try {
const node = await services.files.resolve(stringParam(req.query.path) ?? '/');
if (node.kind === 'file') return res.json({ path: node.path, kind: 'file', matches: [] });
const type = stringParam(req.query.type) ?? 'any';
const matches = nameMatcher(name);
const result = await services.files.walk(node, {
maxDepth: 12,
maxDirectories: boundedInt(req.query.maxFolders, 400, 1, 1000),
});
return res.json({
path: node.path,
kind: 'directory',
matches: result.entries
.filter((entry) => (type === 'file' ? entry.file : type === 'folder' ? entry.directory : true))
.filter((entry) => matches((entry.file ?? entry.directory)?.name ?? ''))
.sort((a, b) => compareNames(a.path, b.path))
.map(treeEntry),
visited: result.visited,
truncated: result.truncated,
failures: result.failures,
});
} catch (error) {
return fsFail(res, error, 'fs find');
}
});
router.get('/fs/file', async (req: Request, res: Response) => {
try {
const path = stringParam(req.query.path);
const id = stringParam(req.query.id);
let file: Pick<FmFile, 'id' | 'name'> & Partial<FmFile>;
if (path) {
const node = await services.files.resolve(path);
if (node.kind !== 'file') return res.status(400).json({ error: 'not_a_file', message: `${node.path} is a folder.` });
file = node.file;
} else if (id && /^[0-9a-f]{24}$/i.test(id)) {
file = { id, name: stringParam(req.query.name) ?? id };
} else {
return res.status(400).json({ error: 'bad_request', message: 'Give path, or id (and name).' });
}
if (file.blocked) {
return res.status(403).json({ error: 'blocked', message: 'The instance virus scanner blocked this file.' });
}
// Streamed straight through rather than buffered: the CLI uses this for
// whole folders, and videos routinely exceed any sensible in-memory cap.
const signed = await services.client.getFileManagerSignedUrl(file.id, file.name);
const upstream = await services.client.openSignedUrl(signed);
if (!upstream.body) return res.status(502).json({ error: 'upstream_failed', message: 'empty response' });
res.setHeader('Content-Type', file.mimeType || upstream.headers.get('content-type') || 'application/octet-stream');
res.setHeader('Content-Disposition', contentDisposition(file.name));
const length = upstream.headers.get('content-length');
if (length) res.setHeader('Content-Length', length);
res.setHeader('X-Schulcloud-Source', 'file-manager');
Readable.fromWeb(upstream.body as never).pipe(res);
} catch (error) {
return fsFail(res, error, 'fs file');
}
});
/**
* Streams one file. Served from the local mirror when present; otherwise
* proxied live, which is what keeps files too large to mirror reachable.
@@ -91,16 +238,241 @@ export function createApiRouter(services: Services): Router {
});
}
}
const known = await services.store.fileSource(fileId);
if (known?.source === 'file-manager') return await proxyFileManager(services, fileId, known, res);
return await proxyLive(services, fileId, res);
} catch (error) {
return fail(res, error, 'file');
}
});
// --- the Schulcloud session token -------------------------------------------
//
// A write, but to this server's own state: the token it reads Schulcloud with.
// The only upstream call is the GET /me a replacement must pass first. Works
// without an index, since a server without one still needs a token.
// --- the user's own lesson notes ----------------------------------------
//
// Read off disk, like the fs_* routes read Schulcloud: no index involved, so
// these answer before the first crawl and while Postgres is down. The POST is
// the only write in this server that is not the index or its own token, and
// it can reach nothing but the notes directory — `writeNote` builds every
// path component with `safeComponent` and checks the result with
// `resolveWithin`.
router.get('/notes', async (req: Request, res: Response) => {
const root = services.config.notesDir;
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });
try {
const path = stringParam(req.query.path);
if (path) return res.json(await readNoteAt(root, path));
const notes = filterNotes(await readNotes(root), {
...pickParam('subject', req.query.subject),
...pickParam('since', req.query.since),
...pickParam('until', req.query.until),
...pickParam('courseId', req.query.courseId),
});
const limit = Math.min(Number.parseInt(stringParam(req.query.limit) ?? '', 10) || 500, 2000);
return res.json({
root,
writable: services.config.notesWritable,
count: notes.length,
// The body is dropped from a listing: a term of notes is megabytes,
// and the CLI asks for the ones it wants by path.
notes: notes.slice(0, limit).map(({ text, ...rest }) => rest),
});
} catch (error) {
if (error instanceof NoteNotFound) return res.status(404).json({ error: 'not_found', message: error.message });
return fail(res, error, 'notes');
}
});
router.post('/notes', express.json({ limit: '1mb' }), async (req: Request, res: Response) => {
const root = services.config.notesDir;
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });
if (!services.config.notesWritable) {
return res.status(403).json({ error: 'notes_readonly', message: 'This server was started with NOTES_READONLY.' });
}
const body = (req.body ?? {}) as Record<string, unknown>;
const title = typeof body.title === 'string' ? body.title.trim() : '';
const noteText = typeof body.text === 'string' ? body.text : '';
if (!title || !noteText.trim()) {
return res.status(400).json({ error: 'invalid', message: 'A note needs a title and some text.' });
}
try {
const { note, appended } = await writeNote(root, {
title,
text: noteText,
...pickParam('date', body.date),
...pickParam('subject', body.subject),
...pickParam('courseId', body.courseId),
...pickParam('path', body.path),
...pickParam('source', body.source),
...(Array.isArray(body.tags) ? { tags: body.tags.filter((tag): tag is string => typeof tag === 'string') } : {}),
append: body.append === true,
});
return res.status(appended ? 200 : 201).json({ ...note, appended });
} catch (error) {
return fail(res, error, 'save a note');
}
});
// --- one school day, as the notes page edits it -------------------------
//
// The page is a Markdown editor for a single file, so these two are `GET the
// day` and `PUT the day`. What makes them worth their own routes rather than
// the generic ones above is the skeleton: WebUntis is the only thing that
// knows which lessons a day held, and handing someone their day already laid
// out is the difference between a note per day and an empty box.
router.get('/notes/day', async (req: Request, res: Response) => {
const root = services.config.notesDir;
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });
const date = stringParam(req.query.date) ?? schoolToday();
if (!isCalendarDate(date)) {
return res.status(400).json({ error: 'invalid', message: `Not a date in the calendar: ${date}.` });
}
try {
const path = dayNotePath(date);
const note = await readNoteAt(root, path).catch((error: unknown) => {
if (error instanceof NoteNotFound) return undefined;
throw error;
});
// Never fatal, and reported rather than hidden: without a key, or with
// WebUntis down, the page still has to open — it just cannot offer the
// lessons, and saying so beats an empty skeleton that looks like a day
// with no school.
let lessons: ReturnType<typeof dayLessons> = [];
let timetable: 'ok' | 'off' | 'unavailable' = services.untis ? 'ok' : 'off';
if (services.untis) {
try {
lessons = dayLessons(await services.untis.timetable(date, date), date);
} catch {
timetable = 'unavailable';
}
}
return res.json({
date,
path,
title: dayNoteTitle(date),
exists: Boolean(note),
text: note?.text ?? '',
modifiedAt: note?.modifiedAt ?? null,
timetable,
lessons,
skeleton: dayNoteSkeleton(lessons),
// What the page would add to a note already started, so "top up the
// day" never rewrites what is there.
missing: note ? dayNoteSkeleton(missingHeadings(note.text, lessons)) : '',
});
} catch (error) {
return fail(res, error, 'read a day note');
}
});
router.put('/notes/day', express.json({ limit: '2mb' }), async (req: Request, res: Response) => {
const root = services.config.notesDir;
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });
if (!services.config.notesWritable) {
return res.status(403).json({ error: 'notes_readonly', message: 'This server was started with NOTES_READONLY.' });
}
const body = (req.body ?? {}) as { date?: unknown; text?: unknown; expectedModifiedAt?: unknown };
const date = typeof body.date === 'string' ? body.date : '';
if (!isCalendarDate(date)) {
return res.status(400).json({ error: 'invalid', message: `Not a date in the calendar: ${date || '(none)'}.` });
}
if (typeof body.text !== 'string') {
return res.status(400).json({ error: 'invalid', message: 'A day note needs its text.' });
}
try {
const note = await replaceNote(
root,
dayNotePath(date),
{ title: dayNoteTitle(date), text: body.text, date, source: 'notes-page' },
typeof body.expectedModifiedAt === 'string' ? { expectedModifiedAt: body.expectedModifiedAt } : {},
);
return res.json({ path: note.path, modifiedAt: note.modifiedAt, bytes: note.bytes });
} catch (error) {
// A clash is the caller's to resolve, not a fault: the page shows both
// and lets the person decide, which is the only safe answer when the
// notes folder is synced and open in two places.
if (error instanceof NoteConflict) {
return res.status(409).json({ error: 'conflict', message: error.message, modifiedAt: error.modifiedAt });
}
return fail(res, error, 'save a day note');
}
});
router.get('/token', (_req: Request, res: Response) => {
res.json(tokenStatus(services));
});
router.put('/token', express.json({ limit: '16kb' }), async (req: Request, res: Response) => {
const jwt = (req.body as { jwt?: unknown } | undefined)?.jwt;
if (typeof jwt !== 'string' || !jwt.trim()) {
return res.status(400).json({ error: 'missing_jwt', message: 'Send {"jwt": "<the value of the jwt cookie>"}.' });
}
try {
const { changed, persisted } = await services.session.replace(jwt);
if (changed) console.log('[schulcloud-mcp] session token replaced at runtime');
return res.json({ changed, persisted, ...tokenStatus(services) });
} catch (error) {
if (error instanceof TokenRejected) return res.status(422).json({ error: error.problem, message: error.message });
// Anything else is the instance failing to answer the check. The error
// cannot contain the token — SchulcloudApiError carries only a path — but
// the response still says no more than that.
const detail = error instanceof SchulcloudApiError ? `HTTP ${error.status}` : error instanceof Error ? error.name : 'error';
console.error(`[schulcloud-mcp] token check failed: ${detail}`);
return res.status(502).json({
error: 'check_failed',
message: `Schulcloud did not answer the check (${detail}); the token in use is unchanged. Try again shortly.`,
});
}
});
// A body that is not JSON would otherwise reach Express's default handler,
// which logs it — and here the body is a credential.
router.use((error: unknown, _req: Request, res: Response, next: (error?: unknown) => void) => {
const type = (error as { type?: string } | undefined)?.type;
if (type === 'entity.parse.failed' || type === 'entity.too.large') {
res.status(400).json({ error: 'bad_request' });
return;
}
next(error);
});
return router;
}
function tokenStatus(services: Services) {
return { ...services.session.status(), keepalive: services.keepalive?.state() ?? null };
}
/** Falls back to Schulcloud for anything not in the mirror, streaming through. */
/** Streams a file-manager file live, via its pre-signed URL; no credentials leave for the storage host. */
async function proxyFileManager(
services: Services,
fileId: string,
known: { name: string; mimeType: string; size: number },
res: Response,
): Promise<void> {
const signed = await services.client.getFileManagerSignedUrl(fileId, known.name);
const upstream = await services.client.openSignedUrl(signed);
if (!upstream.body) {
res.status(502).json({ error: 'upstream_failed', message: 'empty response' });
return;
}
res.setHeader('Content-Type', known.mimeType || 'application/octet-stream');
res.setHeader('Content-Disposition', contentDisposition(known.name));
const length = upstream.headers.get('content-length');
if (length) res.setHeader('Content-Length', length);
res.setHeader('X-Schulcloud-Source', 'live');
Readable.fromWeb(upstream.body as never).pipe(res);
}
async function proxyLive(services: Services, fileId: string, res: Response): Promise<void> {
const record = await services.client.getFileRecord(fileId);
if (record.securityCheckStatus === 'blocked') {
@@ -143,3 +515,74 @@ function fail(res: Response, error: unknown, what: string): void {
console.error(`[schulcloud-mcp] ${what} failed:`, error);
if (!res.headersSent) res.status(500).json({ error: 'internal_error' });
}
function stringParam(value: unknown): string | undefined {
const first = Array.isArray(value) ? value[0] : value;
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;
}
function childPath(parent: string, name: string): string {
return `${parent === '/' ? '' : parent}/${name}`;
}
/** A walk entry as JSON; `name` travels separately because names may contain "/". */
function treeEntry(entry: WalkEntry) {
if (entry.directory) {
return { type: 'directory', path: entry.path, parentPath: entry.parentPath, depth: entry.depth, id: entry.directory.id, name: entry.directory.name };
}
const file = entry.file as FmFile;
return {
type: 'file',
path: entry.path,
parentPath: entry.parentPath,
depth: entry.depth,
id: file.id,
name: file.name,
size: file.size,
mimeType: file.mimeType ?? null,
blocked: file.blocked,
};
}
const FS_STATUS: Record<FsErrorCode, number> = {
not_found: 404,
ambiguous: 409,
not_a_directory: 400,
not_a_file: 400,
not_navigable: 422,
};
function fsFail(res: Response, error: unknown, what: string): void {
if (res.headersSent) return;
if (error instanceof FsError) {
res.status(FS_STATUS[error.code]).json({ error: error.code, message: error.message });
return;
}
if (error instanceof FileManagerMarkupError) {
res.status(502).json({ error: 'markup_changed', message: error.message });
return;
}
if (error instanceof SchulcloudApiError) {
// 401 upstream is the Pi's session, not the caller's token: say which.
const status = error.status === 401 ? 502 : error.status === 403 || error.status === 404 ? error.status : 502;
const message = error.status === 401 ? 'The Schulcloud session has expired on the server.' : error.message;
res.status(status).json({ error: 'upstream_failed', message });
return;
}
fail(res, error, what);
}

144
src/http/app-page.ts Normal file
View File

@@ -0,0 +1,144 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import express, { type Request, type Response, type Router } from 'express';
import type { Config } from '../config.ts';
import { createWebAuth, isSecureRequest, sessionAuth, type WebAuth } from './web-auth.ts';
/**
* `/app` — the notes app, for a person rather than a program.
*
* Everything else this server exposes is for a machine with a token. This is
* the one surface a human opens on a phone, so it gets a login, a session
* cookie and an interface: the day's notes, and the settings page where the
* Schulcloud token is replaced when it expires.
*
* It is served only when `WEB_PASSWORD` is set, by the same rule as the
* `untis_*` tools and the note tools: an app whose login nothing can open is
* worse than no app, because it looks like a way in.
*
* The assets are files, not strings in this module. They are real HTML, CSS
* and JavaScript that an editor and a linter understand, and the content
* security policy forbids inline script anyway — so the only thing gained by
* embedding them would be a build step that no longer copies them, and the
* only thing lost would be every tool that reads them.
*
* `app.js` is an ES module and imports the other two, which is also what lets
* `markdown.js` — the Markdown the editor reads and writes — be tested under
* `node --test` rather than only in a browser.
*/
/** No outside resources at all, and no inline script. Nothing here needs either. */
const CSP =
"default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; " +
"connect-src 'self'; manifest-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
const HEADERS: Record<string, string> = {
'Content-Security-Policy': CSP,
'Referrer-Policy': 'no-referrer',
'X-Content-Type-Options': 'nosniff',
// The app reflects an account's data; a shared phone should not show it from
// the back-forward cache after a logout.
'Cache-Control': 'no-store',
};
const ASSETS: Record<string, { file: string; type: string }> = {
'/': { file: 'index.html', type: 'text/html; charset=utf-8' },
'/index.html': { file: 'index.html', type: 'text/html; charset=utf-8' },
'/app.css': { file: 'app.css', type: 'text/css; charset=utf-8' },
'/app.js': { file: 'app.js', type: 'text/javascript; charset=utf-8' },
'/editor.js': { file: 'editor.js', type: 'text/javascript; charset=utf-8' },
'/markdown.js': { file: 'markdown.js', type: 'text/javascript; charset=utf-8' },
'/icon.svg': { file: 'icon.svg', type: 'image/svg+xml' },
'/manifest.webmanifest': { file: 'manifest.webmanifest', type: 'application/manifest+json' },
};
/**
* Read once at startup, from next to this module.
*
* `import.meta.dirname` resolves to `src/http` when the tree is run directly
* and `dist/http` after a build, and `scripts/copy-assets.mjs` puts the folder
* in both — so there is one path and no branch on how the server was started.
*/
const assetRoot = join(import.meta.dirname, 'app');
const cache = new Map<string, Buffer>();
function asset(file: string): Buffer {
let bytes = cache.get(file);
if (!bytes) {
bytes = readFileSync(join(assetRoot, file));
cache.set(file, bytes);
}
return bytes;
}
export interface AppSurface {
router: Router;
/** The gate `/api` also accepts, so the app's own fetches need no token. */
auth: WebAuth;
}
export function createAppRouter(config: Config): AppSurface | undefined {
const auth = createWebAuth(config.webPassword);
if (!auth.enabled) return undefined;
const router = express.Router();
const requireSession = sessionAuth(auth);
router.use((_req: Request, res: Response, next) => {
for (const [name, value] of Object.entries(HEADERS)) res.setHeader(name, value);
next();
});
// The shell is public: it is the login screen, and it holds nothing. Every
// byte of data it goes on to show comes from /api, behind the session.
router.get(
/^\/(index\.html|app\.css|app\.js|editor\.js|markdown\.js|icon\.svg|manifest\.webmanifest)?$/,
(req: Request, res: Response) => {
const entry = ASSETS[req.path] ?? ASSETS['/']!;
res.type(entry.type).send(asset(entry.file));
},
);
router.post('/login', express.json({ limit: '4kb' }), (req: Request, res: Response) => {
const password = (req.body as { password?: unknown } | undefined)?.password;
if (typeof password !== 'string' || password.length === 0) {
return res.status(400).json({ error: 'invalid', message: 'Passwort fehlt.' });
}
// The address is the rate-limit key. Behind Caddy every request comes from
// the proxy, so the forwarded address is what distinguishes callers; it is
// spoofable by anyone who can reach this process directly, which on this
// deployment is nobody.
const from = (req.get('x-forwarded-for') ?? '').split(',')[0]?.trim() || req.ip || 'unknown';
const result = auth.check(password, from);
if (!result.ok) {
if (result.retryAfterSeconds !== undefined) {
res.setHeader('Retry-After', String(result.retryAfterSeconds));
return res.status(429).json({
error: 'too_many_attempts',
message: `Zu viele Fehlversuche. In ${Math.ceil(result.retryAfterSeconds / 60)} Minute(n) erneut versuchen.`,
});
}
// Deliberately no detail, and the same shape for every miss.
return res.status(401).json({ error: 'unauthorized' });
}
res.setHeader('Set-Cookie', auth.cookie(auth.mint(), { secure: isSecureRequest(req) }));
return res.json({ authenticated: true });
});
router.post('/logout', (req: Request, res: Response) => {
res.setHeader('Set-Cookie', auth.clearCookie({ secure: isSecureRequest(req) }));
return res.json({ authenticated: false });
});
// Always 200: "are you logged in" is not itself a protected question, and a
// 401 here would make the first load of the login screen look like an error.
router.get('/session', (req: Request, res: Response) => {
return res.json({ authenticated: auth.verify(req.get('cookie')) });
});
// Anything else under /app needs the session — there is nothing else to
// serve, but a 404 that leaks the shape of the tree is still a 404 too many.
router.use(requireSession, (_req: Request, res: Response) => res.status(404).json({ error: 'not_found' }));
return { router, auth };
}

309
src/http/app/app.css Normal file
View File

@@ -0,0 +1,309 @@
/*
* The app is used with one thumb, in a lesson, on a phone that may be at 10%.
* Everything below follows from that: one column, large touch targets, the
* editor taking every pixel that is not navigation, and no webfont — the CSP
* forbids outside resources anyway, and a font that has not loaded is a blank
* screen in a classroom with no signal.
*/
:root {
color-scheme: light dark;
--bg: #ffffff;
--fg: #1f2328;
--muted: #656d76;
--line: #d0d7de;
--accent: #1f6feb;
--ok: #1a7f37;
--error: #cf222e;
--warn: #9a6700;
--card: #f6f8fa;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--fg: #e6edf3;
--muted: #8b949e;
--line: #30363d;
--accent: #4493f8;
--ok: #3fb950;
--error: #f85149;
--warn: #d29922;
--card: #161b22;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
/* Fills the viewport on a phone, where 100vh lies about the toolbar. */
min-height: 100dvh;
display: flex;
flex-direction: column;
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}
.screen { display: flex; flex-direction: column; flex: 1; min-height: 0; }
[hidden] { display: none !important; }
/* --- chrome ------------------------------------------------------------ */
header { border-bottom: 1px solid var(--line); }
.tabs { display: flex; }
.tab {
flex: 1;
padding: 0.9rem 0.5rem;
border: 0;
border-bottom: 2px solid transparent;
background: none;
color: var(--muted);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.tab[aria-current="page"] { color: var(--fg); border-bottom-color: var(--accent); }
.view { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0.75rem; gap: 0.5rem; }
/* --- the day bar ------------------------------------------------------- */
.daybar { display: flex; align-items: center; gap: 0.5rem; }
.daybar button {
flex: 0 0 auto;
width: 2.75rem;
height: 2.75rem;
font-size: 1.5rem;
line-height: 1;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--card);
color: var(--fg);
cursor: pointer;
}
.daybar-centre { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 0.15rem; }
.daybar-centre strong { font-size: 1.05rem; }
.daybar-centre input { border: 0; background: none; color: var(--muted); font: inherit; font-size: 0.85rem; }
/* --- the toolbar ------------------------------------------------------- */
/*
* One row that scrolls sideways rather than wrapping into two: a second row
* would take a line of editor away from every note to hold buttons that are
* used once a lesson, and a thumb swipes a row far more easily than it hunts
* through a grid.
*/
.toolbar {
display: flex;
align-items: center;
gap: 0.25rem;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 0.15rem;
}
.toolbar::-webkit-scrollbar { display: none; }
.toolbar button {
flex: 0 0 auto;
min-width: 2.5rem;
height: 2.5rem;
padding: 0 0.5rem;
font-size: 0.9rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
.toolbar button[aria-pressed="true"] {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--card));
}
.toolbar button code { font-family: ui-monospace, monospace; font-size: 0.85rem; }
.sep { flex: 0 0 auto; width: 1px; height: 1.5rem; background: var(--line); margin: 0 0.15rem; }
/* --- the editor -------------------------------------------------------- */
/*
* The formatted document. It is the note as it will read, not as it is stored
* — the file underneath is still Markdown, and `MD` in the toolbar shows it.
*/
.editor {
flex: 1;
min-height: 12rem;
overflow-y: auto;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--bg);
color: var(--fg);
font-size: 1rem;
line-height: 1.55;
/* A long URL or a wide table must not push the page sideways. */
overflow-wrap: break-word;
}
.editor:focus-visible { outline: 2px solid var(--accent); outline-offset: -1px; }
.editor.empty::before {
content: attr(data-placeholder);
color: var(--muted);
pointer-events: none;
}
.editor > :first-child { margin-top: 0; }
.editor > :last-child { margin-bottom: 0; }
.editor p { margin: 0 0 0.75rem; }
/* A lesson heading is the note's structure — each one is indexed as its own
lesson — so it is given a rule to sit on rather than just a larger size. */
.editor h2 {
margin: 1.25rem 0 0.5rem;
padding-bottom: 0.2rem;
border-bottom: 1px solid var(--line);
font-size: 1.1rem;
}
.editor h1 { font-size: 1.25rem; margin: 1.25rem 0 0.5rem; }
.editor h3, .editor h4, .editor h5, .editor h6 { margin: 1rem 0 0.35rem; font-size: 1rem; }
.editor ul, .editor ol { margin: 0 0 0.75rem; padding-left: 1.4rem; }
.editor li { margin: 0.15rem 0; }
.editor li.task { list-style: none; margin-left: -1.2rem; }
.editor li.task input { margin-right: 0.4rem; }
.editor blockquote {
margin: 0 0 0.75rem;
padding-left: 0.75rem;
border-left: 3px solid var(--line);
color: var(--muted);
}
.editor code {
padding: 0.1em 0.3em;
border-radius: 0.25rem;
background: var(--card);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.9em;
}
.editor pre {
margin: 0 0 0.75rem;
padding: 0.6rem 0.75rem;
border-radius: 0.5rem;
background: var(--card);
overflow-x: auto;
}
.editor pre code { padding: 0; background: none; }
.editor hr { border: 0; border-top: 1px solid var(--line); margin: 1rem 0; }
.editor a { color: var(--accent); }
/* A table wider than the phone scrolls inside the note rather than stretching
it: the caret has to stay reachable. */
.editor table { display: block; overflow-x: auto; border-collapse: collapse; margin: 0 0 0.75rem; font-size: 0.9rem; }
.editor th, .editor td { border: 1px solid var(--line); padding: 0.3rem 0.5rem; text-align: left; min-width: 3rem; }
.editor th { background: var(--card); }
textarea {
flex: 1;
min-height: 12rem;
width: 100%;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--bg);
color: var(--fg);
/* Monospace: this is the Markdown view, and headings and list markers have
to line up to be read back as structure. */
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.95rem;
line-height: 1.5;
resize: none;
}
.actions { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
button {
padding: 0.65rem 1rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--card);
color: var(--fg);
font: inherit;
cursor: pointer;
}
button:disabled { opacity: 0.5; cursor: default; }
#save, #login-form button, #token-form button {
background: var(--accent);
border-color: var(--accent);
color: #ffffff;
font-weight: 600;
}
/* --- text -------------------------------------------------------------- */
.status { margin: 0; color: var(--muted); font-size: 0.85rem; min-height: 1.2em; }
.hint { color: var(--muted); font-size: 0.85rem; }
.ok { color: var(--ok); }
.error { color: var(--error); margin: 0.5rem 0 0; }
.warn { color: var(--warn); }
.conflict {
margin: 0;
padding: 0.6rem 0.75rem;
border: 1px solid var(--warn);
border-radius: 0.5rem;
color: var(--warn);
font-size: 0.9rem;
}
/* --- cards (login, settings) ------------------------------------------- */
.card {
margin: 0.75rem;
padding: 1rem;
border: 1px solid var(--line);
border-radius: 0.75rem;
background: var(--card);
}
.card h1, .card h2 { margin-top: 0; font-size: 1.15rem; }
label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 600; font-size: 0.9rem; }
input[type="password"], input[type="text"] {
width: 100%;
padding: 0.7rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--bg);
color: var(--fg);
font: inherit;
}
#login { justify-content: center; }
#login .card { width: min(24rem, 100%); align-self: center; }
#login button { width: 100%; margin-top: 1rem; }
.steps { margin: 0.5rem 0; padding-left: 1.1rem; color: var(--muted); font-size: 0.85rem; line-height: 1.5; }
.steps code { font-family: ui-monospace, monospace; }
#token-form button, #logout { margin-top: 0.75rem; }
dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 0.35rem 0.75rem; font-size: 0.9rem; }
dt { color: var(--muted); }
dd { margin: 0; }

534
src/http/app/app.js Normal file
View File

@@ -0,0 +1,534 @@
import { createEditor } from './editor.js';
/*
* The notes app.
*
* One school day is one note, one lesson is one `##` heading, and the server
* builds the headings from WebUntis — so opening the app during a free period
* gives you the day already laid out rather than an empty box. That shape is
* also what makes each lesson separately searchable afterwards, which is the
* whole reason the notes are worth writing here rather than in Notes.app.
*
* Three rules this file exists to honour:
*
* - **Never lose what was typed.** Every keystroke goes to localStorage, and a
* draft that is newer than the server's copy survives a dead connection, a
* locked phone and a closed tab. A note taken in a lesson cannot be retaken.
* - **Never silently overwrite.** Saves carry the modification time the editor
* loaded; the server refuses one that would clobber a version this editor
* never saw, and the banner then makes it the person's decision.
* - **Say what state it is in.** "Gespeichert 14:02", "Nicht gespeichert",
* "Offline — lokal gesichert". A silent editor over a flaky connection is
* indistinguishable from one that is losing your work.
*
* What the person sees is formatted text with a toolbar; what is written to
* disk is Markdown. `editor.js` is the whole of that translation — everything
* here deals in Markdown strings and never touches the document.
*/
const AUTOSAVE_MS = 2500;
const DRAFT_PREFIX = 'schulcloud-mcp/draft/';
const ui = {
login: document.getElementById('login'),
loginForm: document.getElementById('login-form'),
password: document.getElementById('password'),
loginError: document.getElementById('login-error'),
app: document.getElementById('app'),
tabNotes: document.getElementById('tab-notes'),
tabSettings: document.getElementById('tab-settings'),
viewNotes: document.getElementById('view-notes'),
viewSettings: document.getElementById('view-settings'),
prev: document.getElementById('prev'),
next: document.getElementById('next'),
dayTitle: document.getElementById('day-title'),
dayDate: document.getElementById('day-date'),
dayStatus: document.getElementById('day-status'),
conflict: document.getElementById('day-conflict'),
toolbar: document.getElementById('toolbar'),
editor: document.getElementById('editor'),
source: document.getElementById('source'),
editorHint: document.getElementById('editor-hint'),
save: document.getElementById('save'),
fill: document.getElementById('fill'),
lessonsHint: document.getElementById('lessons-hint'),
tokenState: document.getElementById('token-state'),
tokenForm: document.getElementById('token-form'),
jwt: document.getElementById('jwt'),
tokenResult: document.getElementById('token-result'),
serverState: document.getElementById('server-state'),
logout: document.getElementById('logout'),
};
/** Everything about the day currently open. */
const day = {
date: today(),
path: '',
/** The server's modification time for the loaded note, or null if there is none. */
modifiedAt: null,
/** The text as the server last confirmed it, to tell "dirty" from "saved". */
saved: '',
/** Headings the timetable has and the note does not. */
missing: '',
dirty: false,
conflicted: false,
timer: 0,
};
/**
* The formatted editor over the two elements that hold a note.
*
* It owns the document and the toolbar; this file only ever asks it for
* Markdown and hands it Markdown back.
*/
const editor = createEditor({
rich: ui.editor,
source: ui.source,
toolbar: ui.toolbar,
onInput: markDirty,
onModeChange: (mode) => {
// Switching by hand is not a warning, so the automatic one goes away.
hint(mode === 'source' ? 'Markdown-Ansicht. „MD" führt zurück.' : '');
},
});
function hint(message) {
ui.editorHint.textContent = message;
ui.editorHint.hidden = !message;
}
// --- plumbing ------------------------------------------------------------
async function api(path, options) {
const response = await fetch(path, {
credentials: 'same-origin',
...options,
headers: { accept: 'application/json', ...(options && options.headers) },
});
if (response.status === 401) {
showLogin();
throw new Error('unauthorized');
}
let body = null;
try {
body = await response.json();
} catch (error) {
body = null;
}
if (!response.ok) {
const failure = new Error((body && (body.message || body.error)) || 'HTTP ' + response.status);
failure.status = response.status;
failure.body = body;
throw failure;
}
return body;
}
function json(method, path, payload) {
return api(path, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) });
}
function today() {
// The device's own date. The server keeps school dates in Europe/Berlin, but
// the phone in the lesson is in that timezone by definition.
const now = new Date();
return [now.getFullYear(), pad(now.getMonth() + 1), pad(now.getDate())].join('-');
}
function pad(value) {
return String(value).padStart(2, '0');
}
function shiftDate(date, days) {
// Noon, so a daylight-saving change cannot push the result onto the
// neighbouring day.
const at = new Date(date + 'T12:00:00');
at.setDate(at.getDate() + days);
return [at.getFullYear(), pad(at.getMonth() + 1), pad(at.getDate())].join('-');
}
function clock() {
const now = new Date();
return pad(now.getHours()) + ':' + pad(now.getMinutes());
}
// --- drafts: the safety net ---------------------------------------------
function draftKey(date) {
return DRAFT_PREFIX + date;
}
function saveDraft(text) {
try {
const value = text === undefined ? editor.getMarkdown() : text;
localStorage.setItem(draftKey(day.date), JSON.stringify({ text: value, at: Date.now() }));
} catch (error) {
// A full or disabled localStorage must not break typing; the server copy
// is still the real one.
}
}
function readDraft(date) {
try {
const raw = localStorage.getItem(draftKey(date));
return raw ? JSON.parse(raw) : null;
} catch (error) {
return null;
}
}
function clearDraft(date) {
try {
localStorage.removeItem(draftKey(date));
} catch (error) {
// Nothing to do: a stale draft is only ever offered, never forced.
}
}
// --- the day -------------------------------------------------------------
function setStatus(message, kind) {
ui.dayStatus.textContent = message;
ui.dayStatus.className = 'status' + (kind ? ' ' + kind : '');
}
async function loadDay(date) {
// Anything unsaved goes to the draft before the view moves, or switching
// days would be a way to lose a lesson.
if (day.dirty) saveDraft();
window.clearTimeout(day.timer);
day.date = date;
day.conflicted = false;
ui.conflict.hidden = true;
ui.dayDate.value = date;
editor.setEnabled(false);
hint('');
setStatus('Wird geladen …');
let info;
try {
info = await api('/api/notes/day?date=' + encodeURIComponent(date));
} catch (error) {
if (error.message === 'unauthorized') return;
ui.dayTitle.textContent = date;
// A reply with a status is the server saying no — most often that it keeps
// no notes at all — and reporting that as "offline" would send someone
// looking at their signal instead of at NOTES_DIR.
if (error.status) {
editor.setMarkdown('');
editor.setEnabled(false);
setStatus(error.message, 'error');
ui.lessonsHint.textContent = '';
return;
}
// No status: the request never arrived. Fall back to whatever this device
// has, rather than an empty editor that looks like a day with no notes.
const draft = readDraft(date);
editor.setEnabled(true);
editor.setMarkdown(draft ? draft.text : '');
day.saved = '';
day.modifiedAt = null;
day.dirty = Boolean(draft);
setStatus(
draft ? 'Offline — lokale Fassung, nicht gespeichert.' : 'Offline — keine Verbindung zum Server.',
'warn',
);
return;
}
day.path = info.path;
day.modifiedAt = info.modifiedAt;
day.missing = info.missing || '';
ui.dayTitle.textContent = info.title;
const server = info.exists ? info.text : info.skeleton;
const draft = readDraft(date);
// A draft only wins when it differs from what the server holds; otherwise it
// is just the last save echoed back and offering it would be noise.
const useDraft = draft && draft.text !== server && draft.text.trim() !== '';
editor.setEnabled(true);
// The server's text first, and what the editor makes of it is the baseline.
// Opening a note the editor would tidy — a table typed unevenly, `*` for
// italics — must not count as an edit, or simply looking at a day would
// rewrite the file.
const loaded = editor.setMarkdown(server);
day.saved = info.exists ? editor.getMarkdown() : '';
if (useDraft) editor.setMarkdown(draft.text);
day.dirty = editor.getMarkdown() !== day.saved;
// One note in a hundred: something the formatted view cannot hold without
// changing it. It opens as Markdown rather than being quietly reduced.
hint(
!loaded.faithful
? 'Diese Notiz enthält Formatierung, die die formatierte Ansicht nicht unverändert halten kann — deshalb Markdown.'
: editor.mode === 'source'
? 'Markdown-Ansicht. „MD" führt zurück.'
: '',
);
if (useDraft) {
setStatus('Lokale, noch nicht gespeicherte Fassung wiederhergestellt.', 'warn');
} else if (info.exists) {
setStatus('Gespeichert.');
} else if (info.skeleton) {
setStatus('Neuer Tag — Stunden aus WebUntis eingetragen.');
} else {
setStatus('Neuer Tag.');
}
describeLessons(info);
ui.fill.hidden = !day.missing;
}
function describeLessons(info) {
if (info.timetable === 'off') {
ui.lessonsHint.textContent = 'Ohne WebUntis-Schlüssel: Überschriften selbst anlegen.';
return;
}
if (info.timetable === 'unavailable') {
ui.lessonsHint.textContent = 'WebUntis nicht erreichbar — Stunden fehlen.';
return;
}
const count = (info.lessons || []).length;
ui.lessonsHint.textContent = count === 0 ? 'Kein Unterricht an diesem Tag.' : count + ' Stunde(n) laut Stundenplan.';
}
function markDirty() {
const text = editor.getMarkdown();
day.dirty = text !== day.saved;
saveDraft(text);
if (day.conflicted) return;
if (day.dirty) setStatus('Nicht gespeichert …');
window.clearTimeout(day.timer);
day.timer = window.setTimeout(() => void saveDay(true), AUTOSAVE_MS);
}
async function saveDay(automatic) {
window.clearTimeout(day.timer);
if (!day.dirty && automatic) return;
const text = editor.getMarkdown();
setStatus('Wird gespeichert …');
try {
const result = await json('PUT', '/api/notes/day', {
date: day.date,
text,
// Absent for a note that does not exist yet: there is nothing to clash
// with, and sending null would look like "I saw no version".
...(day.modifiedAt ? { expectedModifiedAt: day.modifiedAt } : {}),
});
day.saved = text;
day.modifiedAt = result.modifiedAt;
day.dirty = false;
day.conflicted = false;
ui.conflict.hidden = true;
clearDraft(day.date);
setStatus('Gespeichert ' + clock() + '.', 'ok');
} catch (error) {
if (error.message === 'unauthorized') return;
if (error.status === 409) {
// Stop autosaving: every further attempt would fail the same way, and
// the choice of which version wins is not ours to make.
day.conflicted = true;
ui.conflict.hidden = false;
ui.conflict.textContent =
'Diese Notiz wurde anderswo geändert, seit sie hier geöffnet wurde. ' +
'„Neu laden" verwirft, was hier steht; „Trotzdem speichern" überschreibt die andere Fassung. ' +
'Deine Fassung ist lokal gesichert.';
ensureConflictButtons();
setStatus('Nicht gespeichert — Konflikt.', 'error');
return;
}
if (error.status === 403) {
setStatus('Der Server nimmt keine Änderungen an (NOTES_READONLY).', 'error');
return;
}
setStatus('Nicht gespeichert — ' + error.message + '. Lokal gesichert.', 'error');
}
}
/** The two ways out of a conflict, added once and only when one happens. */
function ensureConflictButtons() {
if (document.getElementById('conflict-reload')) return;
const reload = document.createElement('button');
reload.id = 'conflict-reload';
reload.type = 'button';
reload.textContent = 'Neu laden';
reload.addEventListener('click', () => {
clearDraft(day.date);
void loadDay(day.date);
});
const force = document.createElement('button');
force.id = 'conflict-force';
force.type = 'button';
force.textContent = 'Trotzdem speichern';
force.addEventListener('click', () => {
day.modifiedAt = null;
day.conflicted = false;
ui.conflict.hidden = true;
void saveDay(false);
});
ui.conflict.append(document.createElement('br'), reload, document.createTextNode(' '), force);
}
// --- settings ------------------------------------------------------------
async function loadSettings() {
ui.tokenState.textContent = 'Wird geladen …';
try {
const info = await api('/api/token');
const budget = info.keepalive && info.keepalive.budgetSeconds;
ui.tokenState.textContent =
'Noch ' + info.daysLeft + ' Tag(e) gültig' +
(budget ? ', Sitzung noch ' + Math.round(budget / 60) + ' min' : '') +
' (' + info.source + ').';
ui.tokenState.className = 'status' + (info.daysLeft <= 3 ? ' warn' : '');
} catch (error) {
if (error.message === 'unauthorized') return;
ui.tokenState.textContent = 'Token-Status nicht lesbar: ' + error.message;
ui.tokenState.className = 'status error';
}
ui.serverState.replaceChildren();
try {
const status = await api('/api/status');
addFact('Index', status.crawlId ? 'Stand ' + status.crawlId + ', ' + status.nodes + ' Einträge' : 'leer');
addFact('Dateien', status.files + ' (' + status.extracted + ' mit Text)');
if (status.indexer && status.indexer.running) addFact('Gerade', 'Durchlauf läuft');
} catch (error) {
addFact('Index', 'nicht verfügbar');
}
try {
const notes = await api('/api/notes?limit=1');
addFact('Notizen', notes.count + ' · ' + notes.root + (notes.writable ? '' : ' (schreibgeschützt)'));
} catch (error) {
addFact('Notizen', 'nicht verfügbar');
}
}
function addFact(term, value) {
const dt = document.createElement('dt');
dt.textContent = term;
const dd = document.createElement('dd');
dd.textContent = value;
ui.serverState.append(dt, dd);
}
// --- views ---------------------------------------------------------------
function showLogin() {
ui.app.hidden = true;
ui.login.hidden = false;
ui.password.focus();
}
function showApp() {
ui.login.hidden = true;
ui.app.hidden = false;
}
function showTab(name) {
const notes = name !== 'settings';
ui.viewNotes.hidden = !notes;
ui.viewSettings.hidden = notes;
ui.tabNotes.setAttribute('aria-current', notes ? 'page' : 'false');
ui.tabSettings.setAttribute('aria-current', notes ? 'false' : 'page');
if (!notes) void loadSettings();
}
// --- wiring --------------------------------------------------------------
ui.loginForm.addEventListener('submit', async (event) => {
event.preventDefault();
ui.loginError.textContent = '';
try {
await json('POST', '/app/login', { password: ui.password.value });
ui.password.value = '';
showApp();
await loadDay(day.date);
} catch (error) {
ui.loginError.textContent =
error.status === 429 ? 'Zu viele Versuche. ' + error.message : 'Passwort falsch.';
}
});
ui.logout.addEventListener('click', async () => {
// The draft stays: logging out is not the same as discarding a lesson.
await json('POST', '/app/logout', {}).catch(() => {});
showLogin();
});
ui.tabNotes.addEventListener('click', () => showTab('notes'));
ui.tabSettings.addEventListener('click', () => showTab('settings'));
ui.prev.addEventListener('click', () => void loadDay(shiftDate(day.date, -1)));
ui.next.addEventListener('click', () => void loadDay(shiftDate(day.date, 1)));
ui.dayDate.addEventListener('change', () => {
if (ui.dayDate.value) void loadDay(ui.dayDate.value);
});
ui.save.addEventListener('click', () => void saveDay(false));
ui.fill.addEventListener('click', () => {
// Appended, never merged into place: the person's own text is not something
// to reorder, and a heading in the wrong order is trivial to move.
editor.append(day.missing);
day.missing = '';
ui.fill.hidden = true;
});
// A phone locking, the app going to the background, or the tab closing: all of
// them end the session without a "save" ever being pressed.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && day.dirty) {
saveDraft();
if (!day.conflicted) void saveDay(true);
}
});
window.addEventListener('beforeunload', (event) => {
if (!day.dirty) return;
saveDraft();
event.preventDefault();
event.returnValue = '';
});
ui.tokenForm.addEventListener('submit', async (event) => {
event.preventDefault();
ui.tokenResult.textContent = 'Wird geprüft …';
ui.tokenResult.className = 'status';
try {
const result = await json('PUT', '/api/token', { jwt: ui.jwt.value });
ui.jwt.value = '';
ui.tokenResult.textContent = result.changed
? 'Ersetzt. Noch ' + result.daysLeft + ' Tag(e) gültig.' + (result.persisted ? '' : ' (Nicht dauerhaft gespeichert.)')
: 'Das ist der Token, der bereits benutzt wird.';
ui.tokenResult.className = 'status ok';
void loadSettings();
} catch (error) {
if (error.message === 'unauthorized') return;
ui.tokenResult.textContent = error.message;
ui.tokenResult.className = 'status error';
}
});
// --- start ---------------------------------------------------------------
void (async () => {
try {
const session = await api('/app/session');
if (!session.authenticated) {
showLogin();
return;
}
} catch (error) {
showLogin();
return;
}
showApp();
await loadDay(day.date);
})();

519
src/http/app/editor.js Normal file
View File

@@ -0,0 +1,519 @@
import { markdownFromDom, markdownToHtml } from './markdown.js';
/*
* The formatted editor.
*
* Notes are written in a lesson, with a thumb, on a phone. Typing `##` and
* `**` while a teacher talks is not note-taking, so what this shows is the
* formatted text and a toolbar — and what it writes to disk is still Markdown,
* because that is what the indexer reads and what outlives this app.
*
* `contenteditable` plus `document.execCommand` rather than a framework or an
* editor library: the content security policy allows no outside script, and
* this app has no build step to bundle one in. execCommand is deprecated on
* paper and universally implemented in practice — including on iOS Safari,
* which is the browser that actually matters here — and it brings selection
* handling, native undo and the software keyboard's own behaviour with it.
* A hand-written selection engine would be a much larger thing to get wrong.
*
* Whatever the browser leaves behind in the document is the serializer's
* problem, not this file's: `markdownFromDom` is deliberately tolerant, and
* everything typed, pasted or produced by a command is reduced to the
* supported subset on the way to the file.
*/
export function createEditor(options) {
const rich = options.rich;
const source = options.source;
const toolbar = options.toolbar;
const onInput = options.onInput ?? (() => {});
const onModeChange = options.onModeChange ?? (() => {});
let mode = 'rich';
// Which view the person last chose. Moving to another day should not undo
// that choice, so only a note the formatted view cannot hold overrides it.
let preferred = 'rich';
let enabled = true;
// execCommand's default is to write inline styles (`<span style="font-weight:
// bold">`). Tags survive the trip to Markdown far more reliably, and the
// serializer would only have to undo the styles anyway.
try {
document.execCommand('styleWithCSS', false, false);
} catch {
// Not every engine has it, and none of them needs it to work.
}
// --- reading and writing --------------------------------------------
function getMarkdown() {
return mode === 'source' ? source.value.trim() : markdownFromDom(rich);
}
function setMarkdown(text) {
const value = String(text ?? '');
// A note whose formatting this editor cannot hold opens as Markdown
// rather than being quietly rewritten into something smaller.
const faithful = isStable(value);
setMode(faithful ? preferred : 'source', { silent: true });
source.value = value;
rich.innerHTML = markdownToHtml(value);
ensureTrailingParagraph();
updatePlaceholder();
return { faithful };
}
/**
* Whether the round trip settles.
*
* One pass may tidy the note — `*a*` becomes `_a_`, a ragged table lines up
* — and that is fine, because a note is only ever rewritten once it has been
* edited. A second pass that changes something again is not fine: it means
* this editor does not understand the note, and every save would erode it a
* little further. That is the case where the Markdown view is the honest
* answer.
*/
function isStable(text) {
const once = markdownFromDom(parse(text));
return markdownFromDom(parse(once)) === once;
}
function parse(text) {
const holder = document.createElement('div');
holder.innerHTML = markdownToHtml(text);
return holder;
}
// --- the two modes ---------------------------------------------------
function setMode(next, config) {
if (next === mode) return;
// Carry the text across, so a toggle never costs a word.
if (next === 'source') source.value = markdownFromDom(rich);
else {
rich.innerHTML = markdownToHtml(source.value);
ensureTrailingParagraph();
}
mode = next;
rich.hidden = mode !== 'rich';
source.hidden = mode !== 'source';
toolbar.querySelectorAll('[data-command]').forEach((button) => {
if (button.dataset.command !== 'mode') button.disabled = mode === 'source';
});
const toggle = toolbar.querySelector('[data-command="mode"]');
if (toggle) toggle.setAttribute('aria-pressed', String(mode === 'source'));
updatePlaceholder();
if (!config || !config.silent) onModeChange(mode);
}
function setEnabled(value) {
enabled = value;
rich.contentEditable = value ? 'true' : 'false';
source.disabled = !value;
toolbar.querySelectorAll('button').forEach((button) => {
button.disabled = !value || (mode === 'source' && button.dataset.command !== 'mode');
});
}
/**
* A line after the last block, so there is somewhere to go.
*
* A table, a code block or a rule at the very end of a `contenteditable`
* element is a dead end: there is no node after it to put the caret in, and
* no key that makes one — the note simply cannot be continued. Every engine
* behaves this way, and every editor works around it the same way. The
* paragraph is empty, so it serializes to nothing and never reaches the file.
*/
const TRAILING_TRAP = /^(TABLE|PRE|HR|BLOCKQUOTE|UL|OL)$/;
function ensureTrailingParagraph() {
const last = rich.lastElementChild;
if (!last || !TRAILING_TRAP.test(last.nodeName)) return;
const paragraph = document.createElement('p');
paragraph.appendChild(document.createElement('br'));
rich.appendChild(paragraph);
}
function updatePlaceholder() {
rich.classList.toggle('empty', rich.textContent.trim() === '' && rich.children.length <= 1);
}
// --- commands --------------------------------------------------------
const commands = {
bold: () => document.execCommand('bold'),
italic: () => document.execCommand('italic'),
strike: () => document.execCommand('strikeThrough'),
h2: () => toggleBlock('H2'),
h3: () => toggleBlock('H3'),
quote: () => toggleBlock('BLOCKQUOTE'),
ul: () => document.execCommand('insertUnorderedList'),
ol: () => document.execCommand('insertOrderedList'),
task: insertTask,
code: insertCode,
link: insertLink,
table: insertTable,
mode: () => {
preferred = mode === 'rich' ? 'source' : 'rich';
setMode(preferred);
},
};
/** A second press on the same button goes back to ordinary text. */
function toggleBlock(tag) {
const current = blockAt();
document.execCommand('formatBlock', false, current === tag ? 'P' : tag);
}
function blockAt() {
let node = selectionNode();
while (node && node !== rich) {
if (node.nodeType === 1 && /^(P|DIV|H[1-6]|BLOCKQUOTE|LI|PRE|TD|TH)$/.test(node.nodeName)) return node.nodeName;
node = node.parentNode;
}
return '';
}
function selectionNode() {
const selection = document.getSelection();
if (!selection || selection.rangeCount === 0) return undefined;
const node = selection.getRangeAt(0).startContainer;
return rich.contains(node) ? node : undefined;
}
/**
* A checkbox item.
*
* Built as a list first, so the browser handles the splitting and merging
* of the item the cursor is in, and then given its box.
*/
function insertTask() {
const item = itemAt();
if (item && firstCheckbox(item)) {
// Already a task: take the box away rather than adding a second.
firstCheckbox(item).remove();
return;
}
if (!item) document.execCommand('insertUnorderedList');
const target = itemAt();
if (!target || firstCheckbox(target)) return;
target.classList.add('task');
target.insertBefore(checkbox(false), target.firstChild);
}
function itemAt() {
let node = selectionNode();
while (node && node !== rich) {
if (node.nodeType === 1 && node.nodeName === 'LI') return node;
node = node.parentNode;
}
return undefined;
}
function checkbox(checked) {
const box = document.createElement('input');
box.type = 'checkbox';
box.contentEditable = 'false';
// The attribute, not just the property: the serializer reads the
// document, and a property set by a click leaves no trace in it.
if (checked) box.setAttribute('checked', '');
return box;
}
function firstCheckbox(item) {
const first = item.firstElementChild;
return first && first.nodeName === 'INPUT' && first.type === 'checkbox' ? first : null;
}
function insertCode() {
const selection = document.getSelection();
const text = selection ? selection.toString() : '';
// `insertHTML` and not a wrapping node, so the caret lands inside the
// new element and the browser records one undo step.
document.execCommand('insertHTML', false, '<code>' + escapeHtml(text || 'Code') + '</code>&nbsp;');
}
function insertLink() {
const selection = document.getSelection();
const label = selection ? selection.toString() : '';
const href = window.prompt('Adresse des Links', 'https://');
if (!href || href === 'https://') return;
if (!/^(https?:|mailto:|tel:)/i.test(href)) {
window.alert('Nur http, https, mailto und tel.');
return;
}
if (label) document.execCommand('createLink', false, href);
else document.execCommand('insertHTML', false, '<a href="' + escapeHtml(href) + '">' + escapeHtml(href) + '</a>&nbsp;');
}
/**
* A table, or one more row of the table already under the cursor.
*
* Two jobs on one button because a phone has no Tab key, and adding a row is
* what anyone wants far more often than a second table inside the first.
* `reflect` renames the button so it says which one it will do.
*/
function insertTable() {
const table = tableAt();
if (table) {
addRow(table);
return;
}
const head = '<tr><th><br></th><th><br></th></tr>';
const row = '<tr><td><br></td><td><br></td></tr>';
document.execCommand(
'insertHTML',
false,
'<table><thead>' + head + '</thead><tbody>' + row + row + '</tbody></table><p><br></p>',
);
}
function cellAt() {
let node = selectionNode();
while (node && node !== rich) {
if (node.nodeType === 1 && (node.nodeName === 'TD' || node.nodeName === 'TH')) return node;
node = node.parentNode;
}
return undefined;
}
function tableAt() {
let node = selectionNode();
while (node && node !== rich) {
if (node.nodeType === 1 && node.nodeName === 'TABLE') return node;
node = node.parentNode;
}
return undefined;
}
/** One more row, as wide as the table, with the caret in its first cell. */
function addRow(table) {
const rows = table.querySelectorAll('tr');
const width = Math.max(1, ...Array.from(rows, (row) => row.children.length));
const body = table.querySelector('tbody') ?? table;
const row = document.createElement('tr');
for (let i = 0; i < width; i++) {
const cell = document.createElement('td');
// An empty cell with nothing in it cannot be clicked into in Gecko;
// the break gives the caret somewhere to stand.
cell.appendChild(document.createElement('br'));
row.appendChild(cell);
}
body.appendChild(row);
placeCaret(row.firstElementChild);
}
function placeCaret(node) {
const range = document.createRange();
range.selectNodeContents(node);
range.collapse(true);
const selection = document.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
// --- input -----------------------------------------------------------
function notify() {
ensureTrailingParagraph();
updatePlaceholder();
onInput();
}
rich.addEventListener('input', notify);
source.addEventListener('input', notify);
// A checkbox is the one control inside the document: its state has to reach
// the markup, or the save would not see it.
rich.addEventListener('change', (event) => {
const target = event.target;
if (!target || target.nodeName !== 'INPUT' || target.type !== 'checkbox') return;
if (target.checked) target.setAttribute('checked', '');
else target.removeAttribute('checked');
notify();
});
/**
* Pasted content goes through Markdown before it reaches the document.
*
* A paste from a web page or a Word document carries fonts, colours,
* classes and occasionally script. Converting it to Markdown and parsing it
* back reduces it to exactly what this editor supports — the same subset the
* file will hold — and is the one place where sanitising and formatting are
* the same operation.
*/
rich.addEventListener('paste', (event) => {
if (!enabled || mode !== 'rich') return;
const data = event.clipboardData;
if (!data) return;
const html = data.getData('text/html');
const text = data.getData('text/plain');
if (!html && !text) return;
event.preventDefault();
let markdown;
if (html) {
const holder = document.createElement('div');
// Never assigned to a live document: this element is detached, and
// what comes out of it is Markdown, not markup.
holder.innerHTML = html;
markdown = markdownFromDom(holder);
} else {
markdown = text;
}
document.execCommand('insertHTML', false, markdownToHtml(markdown));
notify();
});
rich.addEventListener('keydown', (event) => {
const modifier = event.metaKey || event.ctrlKey;
if (modifier && !event.altKey) {
const key = event.key.toLowerCase();
const shortcut = { b: 'bold', i: 'italic', k: 'link', e: 'code' }[key];
if (shortcut) {
event.preventDefault();
run(shortcut);
return;
}
}
// Tab walks the cells, and a Tab out of the last one adds a row. This is
// what every table anywhere does, and without it the only way to add a
// row on a keyboard would be the toolbar.
if (event.key === 'Tab' && !modifier) {
const cell = cellAt();
if (cell) {
event.preventDefault();
const cells = Array.from(cell.closest('table').querySelectorAll('th, td'));
const next = cells[cells.indexOf(cell) + (event.shiftKey ? -1 : 1)];
if (next) placeCaret(next);
else if (!event.shiftKey) addRow(cell.closest('table'));
notify();
return;
}
}
// The way out of anything: a new paragraph after the block the cursor is
// in, however deep in a table or a quote it sits.
if (event.key === 'Enter' && modifier) {
event.preventDefault();
let block = selectionNode();
while (block && block.parentNode !== rich) block = block.parentNode;
const paragraph = document.createElement('p');
paragraph.appendChild(document.createElement('br'));
if (block) block.after(paragraph);
else rich.appendChild(paragraph);
placeCaret(paragraph);
notify();
return;
}
// Enter at the end of a task item continues the list as tasks; the
// browser would give the new item no box.
if (event.key === 'Enter' && !event.shiftKey) {
const item = itemAt();
if (item && firstCheckbox(item)) {
window.setTimeout(() => {
const next = itemAt();
if (next && next !== item && !firstCheckbox(next) && next.textContent.trim() === '') {
next.classList.add('task');
next.insertBefore(checkbox(false), next.firstChild);
}
}, 0);
}
}
});
// --- the toolbar -----------------------------------------------------
function run(name) {
const command = commands[name];
if (!command) return;
if (name !== 'mode') {
if (!enabled || mode !== 'rich') return;
rich.focus();
}
command();
notify();
reflect();
}
toolbar.addEventListener('mousedown', (event) => {
// The selection must survive the press, or every command would apply to
// nothing. Touch devices fire this too, ahead of the click.
if (event.target.closest('[data-command]')) event.preventDefault();
});
toolbar.addEventListener('click', (event) => {
const button = event.target.closest('[data-command]');
if (!button) return;
event.preventDefault();
run(button.dataset.command);
});
/** Which buttons are "on" for the cursor's position. */
function reflect() {
if (mode !== 'rich') return;
const block = blockAt();
const states = {
bold: query('bold'),
italic: query('italic'),
strike: query('strikeThrough'),
h2: block === 'H2',
h3: block === 'H3',
quote: block === 'BLOCKQUOTE',
ul: query('insertUnorderedList'),
ol: query('insertOrderedList'),
};
for (const [name, active] of Object.entries(states)) {
const button = toolbar.querySelector('[data-command="' + name + '"]');
if (button) button.setAttribute('aria-pressed', String(Boolean(active)));
}
const table = toolbar.querySelector('[data-command="table"]');
if (table) {
const inside = Boolean(tableAt());
const label = inside ? 'Zeile anfügen' : 'Tabelle';
table.setAttribute('aria-label', label);
table.title = inside ? label + ' (oder Tab in der letzten Zelle)' : label;
}
}
function query(command) {
try {
return document.queryCommandState(command);
} catch {
return false;
}
}
document.addEventListener('selectionchange', () => {
if (selectionNode()) reflect();
});
// --- what the app calls ----------------------------------------------
return {
getMarkdown,
setMarkdown,
setEnabled,
get mode() {
return mode;
},
focus() {
(mode === 'rich' ? rich : source).focus();
},
/** Adds Markdown at the end — how the day's missing lessons arrive. */
append(markdown) {
const current = getMarkdown();
const next = (current ? current.replace(/\s*$/, '') + '\n\n' : '') + markdown;
if (mode === 'source') source.value = next;
else rich.innerHTML = markdownToHtml(next);
notify();
},
};
}
function escapeHtml(value) {
return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

6
src/http/app/icon.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Notizen">
<rect width="64" height="64" rx="14" fill="#1f6feb"/>
<g fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round">
<path d="M18 20h28M18 32h28M18 44h18"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 277 B

117
src/http/app/index.html Normal file
View File

@@ -0,0 +1,117 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#1f6feb">
<title>Schulcloud — Notizen</title>
<link rel="icon" href="icon.svg" type="image/svg+xml">
<link rel="manifest" href="manifest.webmanifest">
<link rel="stylesheet" href="app.css">
</head>
<body>
<noscript>Diese Seite braucht JavaScript.</noscript>
<!-- Login. Shown until /app/session says otherwise; everything else stays hidden. -->
<section id="login" class="screen" hidden>
<form id="login-form" class="card">
<h1>Anmelden</h1>
<label for="password">Passwort</label>
<input id="password" name="password" type="password" autocomplete="current-password" required autofocus>
<button type="submit">Anmelden</button>
<p id="login-error" class="error" role="alert" aria-live="assertive"></p>
</form>
</section>
<div id="app" class="screen" hidden>
<header>
<nav class="tabs">
<button type="button" id="tab-notes" class="tab" aria-current="page">Notizen</button>
<button type="button" id="tab-settings" class="tab">Einstellungen</button>
</nav>
</header>
<!-- Notes: one school day per note, one heading per lesson. -->
<main id="view-notes" class="view">
<div class="daybar">
<button type="button" id="prev" aria-label="Vorheriger Tag"></button>
<div class="daybar-centre">
<strong id="day-title"></strong>
<input id="day-date" type="date" aria-label="Datum">
</div>
<button type="button" id="next" aria-label="Nächster Tag"></button>
</div>
<p id="day-status" class="status" role="status" aria-live="polite"></p>
<p id="day-conflict" class="conflict" role="alert" hidden></p>
<!-- The toolbar writes the Markdown so nobody has to type it. Every button
carries a word as well as a glyph, because the glyph is the thing a
screen reader cannot read and a stranger cannot guess. -->
<div id="toolbar" class="toolbar" role="toolbar" aria-label="Formatierung">
<button type="button" data-command="h2" aria-pressed="false" aria-label="Stunde (Überschrift)" title="Stunde (Überschrift)"><b>H2</b></button>
<button type="button" data-command="h3" aria-pressed="false" aria-label="Zwischenüberschrift" title="Zwischenüberschrift"><b>H3</b></button>
<span class="sep" aria-hidden="true"></span>
<button type="button" data-command="bold" aria-pressed="false" aria-label="Fett" title="Fett (Strg+B)"><b>F</b></button>
<button type="button" data-command="italic" aria-pressed="false" aria-label="Kursiv" title="Kursiv (Strg+I)"><i>K</i></button>
<button type="button" data-command="strike" aria-pressed="false" aria-label="Durchgestrichen" title="Durchgestrichen"><s>S</s></button>
<button type="button" data-command="code" aria-label="Code" title="Code (Strg+E)"><code>&lt;&gt;</code></button>
<span class="sep" aria-hidden="true"></span>
<button type="button" data-command="ul" aria-pressed="false" aria-label="Aufzählung" title="Aufzählung">&nbsp;</button>
<button type="button" data-command="ol" aria-pressed="false" aria-label="Nummerierte Liste" title="Nummerierte Liste">1.&nbsp;</button>
<button type="button" data-command="task" aria-label="Kästchen zum Abhaken" title="Kästchen zum Abhaken"></button>
<button type="button" data-command="quote" aria-pressed="false" aria-label="Zitat" title="Zitat"></button>
<span class="sep" aria-hidden="true"></span>
<button type="button" data-command="link" aria-label="Link" title="Link (Strg+K)">🔗</button>
<button type="button" data-command="table" aria-label="Tabelle" title="Tabelle"></button>
<span class="sep" aria-hidden="true"></span>
<button type="button" data-command="mode" aria-pressed="false" aria-label="Markdown bearbeiten" title="Markdown bearbeiten">MD</button>
</div>
<p id="editor-hint" class="hint" role="status" hidden></p>
<!-- The formatted document, and the same note as Markdown. Exactly one of
the two is visible; both hold the whole note. -->
<div id="editor" class="editor" contenteditable="true" spellcheck="true" autocapitalize="sentences"
role="textbox" aria-multiline="true" aria-label="Notizen des Tages"
data-placeholder="Noch nichts für diesen Tag."></div>
<textarea id="source" class="source" hidden spellcheck="false" autocapitalize="off"
aria-label="Notizen des Tages als Markdown"></textarea>
<div class="actions">
<button type="button" id="save">Speichern</button>
<button type="button" id="fill" hidden>Stunden ergänzen</button>
<span id="lessons-hint" class="hint"></span>
</div>
</main>
<!-- Settings: the Schulcloud token, and what the server is doing. -->
<main id="view-settings" class="view" hidden>
<section class="card">
<h2>Schulcloud-Token</h2>
<p id="token-state" class="status"></p>
<ol class="steps">
<li>In einem privaten Fenster bei der Schulcloud anmelden.</li>
<li>DevTools → Application → Cookies → Wert des Cookies <code>jwt</code> kopieren.</li>
<li>Hier einsetzen und speichern. Der Server prüft ihn erst bei der Schulcloud.</li>
<li><strong>Das private Fenster schließen</strong> — offen gelassen meldet es den Token nach etwa zwei Stunden ab.</li>
</ol>
<form id="token-form">
<label for="jwt">Neuer jwt-Cookie</label>
<input id="jwt" type="password" autocomplete="off" spellcheck="false">
<button type="submit">Token ersetzen</button>
</form>
<p id="token-result" class="status" role="status" aria-live="polite"></p>
</section>
<section class="card">
<h2>Server</h2>
<dl id="server-state"></dl>
<button type="button" id="logout">Abmelden</button>
</section>
</main>
</div>
<script type="module" src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,14 @@
{
"name": "Schulcloud Notizen",
"short_name": "Notizen",
"description": "Notizen zum Schultag, Stunde für Stunde.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "portrait",
"background_color": "#ffffff",
"theme_color": "#1f6feb",
"icons": [
{ "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
]
}

652
src/http/app/markdown.js Normal file
View File

@@ -0,0 +1,652 @@
/*
* Markdown in, formatted text out, and back again.
*
* The notes are Markdown files — that is what the indexer reads, what
* `subjectFromHeading` takes a lesson apart with, and what survives this
* project. The editor shows them as formatted text anyway, so this module is
* the hinge: `markdownToHtml` on the way into the editor, `markdownFromDom` on
* the way back out to the file.
*
* Three properties matter more than completeness, because what passes through
* here is the only record of what was said in a lesson:
*
* - **Round-trip stability.** `fromDom(toHtml(x))` may tidy `x` once — `*a*`
* becomes `_a_`, a ragged table lines up — but doing it again must change
* nothing. `editor.js` checks exactly that before it opens a note in
* formatted mode, and falls back to the Markdown view when it does not hold.
* - **Nothing is dropped.** An element this module does not model keeps its
* words and loses its tag. A note is better off plain than short.
* - **No HTML is trusted.** `markdownToHtml` escapes everything that is not a
* construct it produced itself, so a note containing `<script>` is text, not
* script. Pasted HTML never reaches the document either: it is converted to
* Markdown first and parsed back, which reduces it to the subset below.
*
* The subset is what these notes are made of: headings, paragraphs, bold,
* italic, strikethrough, code (inline and fenced), links, bullet / numbered /
* task lists with nesting, blockquotes, tables and rules. Underline is
* deliberately absent — Markdown has no way to write it, so the toolbar does
* not offer what the file cannot keep.
*/
/**
* Where a code span sat while the emphasis rules ran over the line.
*
* A control character, because it is the one thing a note cannot contain: the
* store strips NUL out of extracted text, and nothing types one.
*/
const PLACEHOLDER = '\u0000';
const PLACEHOLDERS = /\u0000(\d+)\u0000/g;
/** Ordered and bullet items, with their indentation and marker. */
const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/;
/** How far a continuation line must be indented to belong to the item above. */
const CONTINUATION = 2;
// --- Markdown → HTML -----------------------------------------------------
/**
* A note's body as HTML for the editor.
*
* The output is the only HTML the editor ever starts from, which is what makes
* the serializer's job finite.
*/
export function markdownToHtml(markdown) {
const lines = String(markdown ?? '')
.replace(/\r\n?/g, '\n')
.split('\n')
.map(expandLeadingTabs);
return parseBlocks(lines);
}
/**
* Tabs only in the indentation, and only there.
*
* Indentation is measured in columns to decide what nests inside what, so a
* tab has to become a known number of spaces first. Tabs inside the text are
* left alone — in a code block they are content.
*/
function expandLeadingTabs(line) {
const match = /^[ \t]+/.exec(line);
if (!match) return line;
return match[0].replace(/\t/g, ' ') + line.slice(match[0].length);
}
function parseBlocks(lines) {
const out = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) {
i++;
continue;
}
const fence = /^ {0,3}(```+|~~~+)\s*([A-Za-z0-9_+#-]*)\s*$/.exec(line);
if (fence) {
const closing = new RegExp('^ {0,3}' + fence[1][0] + '{' + fence[1].length + ',}\\s*$');
const body = [];
i++;
while (i < lines.length && !closing.test(lines[i])) {
body.push(lines[i]);
i++;
}
// An unclosed fence still ends the block; the note is what it is.
i++;
const language = fence[2] ? ' class="language-' + escapeHtml(fence[2]) + '"' : '';
out.push('<pre><code' + language + '>' + escapeHtml(body.join('\n')) + '</code></pre>');
continue;
}
const heading = /^ {0,3}(#{1,6})\s+(.*?)\s*#*$/.exec(line);
if (heading) {
const level = heading[1].length;
out.push('<h' + level + '>' + inlineToHtml(heading[2]) + '</h' + level + '>');
i++;
continue;
}
if (isRule(line)) {
out.push('<hr>');
i++;
continue;
}
if (/^ {0,3}>/.test(line)) {
const body = [];
while (i < lines.length && lines[i].trim()) {
if (/^ {0,3}>/.test(lines[i])) body.push(lines[i].replace(/^ {0,3}> ?/, ''));
// A wrapped line with no `>` still belongs to the quote it follows.
else body.push(lines[i].trim());
i++;
}
out.push('<blockquote>' + parseBlocks(body) + '</blockquote>');
continue;
}
if (startsTable(lines, i)) {
const header = splitRow(lines[i]);
i += 2;
const rows = [];
while (i < lines.length && lines[i].trim() && lines[i].includes('|')) {
rows.push(splitRow(lines[i]));
i++;
}
out.push(tableToHtml(header, rows));
continue;
}
if (ITEM.test(line)) {
const list = parseList(lines, i);
out.push(list.html);
i = list.next;
continue;
}
// A paragraph, whose single newlines are line breaks rather than
// paragraph breaks. That is how a note reads in a plain editor and how
// Notes.app behaved, and it round-trips exactly — unlike the two
// trailing spaces CommonMark wants, which no one can see.
const paragraph = [];
while (i < lines.length && lines[i].trim() && !startsBlock(lines, i)) {
paragraph.push(lines[i].trim());
i++;
}
out.push('<p>' + paragraph.map(inlineToHtml).join('<br>') + '</p>');
}
return out.join('');
}
/** Everything that interrupts a paragraph. */
function startsBlock(lines, index) {
const line = lines[index];
return (
/^ {0,3}(```+|~~~+)/.test(line) ||
/^ {0,3}#{1,6}\s/.test(line) ||
/^ {0,3}>/.test(line) ||
isRule(line) ||
ITEM.test(line) ||
startsTable(lines, index)
);
}
function isRule(line) {
return /^ {0,3}([-*_])\s*(?:\1\s*){2,}$/.test(line);
}
function startsTable(lines, index) {
if (!lines[index].includes('|')) return false;
const next = lines[index + 1];
return Boolean(next) && /^\s*\|?(\s*:?-{1,}:?\s*\|)+\s*:?-*:?\s*\|?\s*$/.test(next) && next.includes('-');
}
function splitRow(line) {
let value = line.trim();
if (value.startsWith('|')) value = value.slice(1);
if (value.endsWith('|') && !value.endsWith('\\|')) value = value.slice(0, -1);
// Split on pipes that are not escaped, then give the cells their pipes back.
return value.split(/(?<!\\)\|/).map((cell) => cell.trim().replace(/\\\|/g, '|'));
}
function tableToHtml(header, rows) {
const width = Math.max(header.length, ...rows.map((row) => row.length), 1);
const cells = (row, tag) => {
let out = '';
for (let i = 0; i < width; i++) {
// A break in an empty cell: a `<td></td>` with nothing in it cannot be
// clicked into, so a blank cell would be uneditable. It serializes
// back to an empty cell.
out += '<' + tag + '>' + (inlineToHtml(row[i] ?? '') || '<br>') + '</' + tag + '>';
}
return out;
};
const body = rows.map((row) => '<tr>' + cells(row, 'td') + '</tr>').join('');
return '<table><thead><tr>' + cells(header, 'th') + '</tr></thead><tbody>' + body + '</tbody></table>';
}
/**
* One list, and everything nested inside it.
*
* Continuation is by indentation: a line indented at least two columns past
* the item's own marker belongs to that item, which is what makes nesting and
* multi-paragraph items work without tracking marker widths through the
* recursion. Indentation inside an item is relative, so the nested list parses
* as a list of its own.
*/
function parseList(lines, start) {
const first = ITEM.exec(lines[start]);
const base = first[1].length;
const ordered = /^\d/.test(first[2]);
const startNumber = ordered ? Number.parseInt(first[2], 10) : 1;
const items = [];
let i = start;
while (i < lines.length) {
const match = ITEM.exec(lines[i]);
if (!match) break;
// A shallower item ends this list; a deeper one is swallowed below as
// part of the item above it, so reaching one here means the list is over.
if (match[1].length !== base) break;
if (/^\d/.test(match[2]) !== ordered) break;
const body = [match[3]];
i++;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) {
// A blank line keeps the item open only if something indented
// follows it; otherwise the list ends here.
const after = lines[i + 1];
if (after && after.trim() && indentOf(after) >= base + CONTINUATION) {
body.push('');
i++;
continue;
}
break;
}
if (indentOf(line) >= base + CONTINUATION) {
body.push(line.slice(base + CONTINUATION));
i++;
continue;
}
if (ITEM.test(line) || startsBlock(lines, i)) break;
// A wrapped line, typed without indentation.
body.push(line.trim());
i++;
}
items.push(body);
}
const tag = ordered ? 'ol' : 'ul';
const open = ordered && startNumber !== 1 ? '<ol start="' + startNumber + '">' : '<' + tag + '>';
return { html: open + items.map(itemToHtml).join('') + '</' + tag + '>', next: i };
}
function itemToHtml(body) {
const task = /^\[([ xX])\]\s+([\s\S]*)$/.exec(body[0] ?? '');
if (task) body = [task[2], ...body.slice(1)];
let inner = parseBlocks(body);
// A tight item: its first paragraph is the item's own text, not a paragraph
// inside it. Unwrapping only the first keeps multi-paragraph items intact.
inner = inner.replace(/^<p>([\s\S]*?)<\/p>/, '$1');
if (!task) return '<li>' + inner + '</li>';
const checked = task[1] !== ' ';
return (
'<li class="task"><input type="checkbox" contenteditable="false"' +
(checked ? ' checked' : '') +
'>' +
inner +
'</li>'
);
}
function indentOf(line) {
return /^[ ]*/.exec(line)[0].length;
}
/**
* Inline Markdown as HTML.
*
* Code spans are taken out first and put back last, so the stars and
* underscores inside `**bold**` written as code stay literal.
*/
function inlineToHtml(text) {
const literals = [];
const park = (html) => {
literals.push(html);
return PLACEHOLDER + (literals.length - 1) + PLACEHOLDER;
};
// Backslash escapes first, or `\*` would still be read as emphasis and a
// backslashed backtick would still open a code span. Parked as literal
// text, they take no further part in anything.
let value = String(text).replace(/\\([\\`*_[\]#>~|+.()-])/g, (all, character) => park(escapeHtml(character)));
value = value.replace(/(`+)([\s\S]*?)\1/g, (all, fence, body) =>
park('<code>' + escapeHtml(body.replace(/^ (.*) $/, '$1')) + '</code>'),
);
value = escapeHtml(value);
// Links before emphasis: a label may contain either, and a URL may contain
// underscores that are not emphasis. One level of balanced parentheses is
// allowed in the target, because real links have them —
// de.wikipedia.org/wiki/Erörterung_(Textsorte).
value = value.replace(/\[([^\]]*)\]\(((?:[^()\s]|\([^()\s]*\))*)\)/g, (all, label, href) => {
const safe = safeUrl(href);
if (!safe) return label;
return '<a href="' + safe + '">' + (label || safe) + '</a>';
});
value = value.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, '<strong>$2</strong>');
value = value.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
// A single marker, not part of a double one, and not mid-word for `_` —
// otherwise snake_case_names turn into emphasis.
value = value.replace(/(?<!\*)\*(?!\*)(?=\S)([\s\S]*?\S)\*(?!\*)/g, '<em>$1</em>');
value = value.replace(/(?<![\w_])_(?!_)(?=\S)([\s\S]*?\S)_(?![\w_])/g, '<em>$1</em>');
return value.replace(PLACEHOLDERS, (all, index) => literals[Number(index)]);
}
/**
* A link target, or nothing.
*
* The editor's content comes from the user's own notes, but a note can be
* written by anything — an import, a paste from a web page — so a `javascript:`
* url is refused rather than rendered into a document a finger will tap.
*/
function safeUrl(href) {
const value = href.trim();
if (!value) return '';
if (/^[a-z][a-z0-9+.-]*:/i.test(value) && !/^(https?|mailto|tel):/i.test(value)) return '';
return escapeHtml(value).replace(/"/g, '&quot;');
}
function escapeHtml(value) {
return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// --- HTML → Markdown -----------------------------------------------------
/**
* What the editor holds, as the Markdown that will be written to the file.
*
* Deliberately tolerant: browsers put their own tags into a contenteditable
* element (`<div>` for a line, `<span style="font-weight: bold">` after a
* paste, `<font>` on older engines), and none of that may cost a word. An
* element with no meaning here serializes its children.
*
* `root` needs only the read-only parts of the DOM — `nodeType`, `nodeName`,
* `childNodes`, `textContent` and `getAttribute` — so the same function runs
* against a plain tree in the tests.
*/
export function markdownFromDom(root) {
return serializeBlocks(root).replace(/[ \t]+$/gm, '').replace(/\n{3,}/g, '\n\n').trim();
}
const BLOCK_TAGS = new Set([
'P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
'UL', 'OL', 'BLOCKQUOTE', 'PRE', 'HR', 'TABLE', 'SECTION', 'ARTICLE', 'FIGURE',
]);
function serializeBlocks(node) {
const out = [];
let inline = [];
const flush = () => {
if (inline.length === 0) return;
const text = paragraph(inlineFrom(inline));
if (text) out.push(text);
inline = [];
};
for (const child of children(node)) {
if (child.nodeType === 1 && BLOCK_TAGS.has(child.nodeName)) {
flush();
const block = serializeBlock(child);
if (block) out.push(block);
} else {
inline.push(child);
}
}
flush();
return out.join('\n\n');
}
function serializeBlock(element) {
switch (element.nodeName) {
case 'H1':
case 'H2':
case 'H3':
case 'H4':
case 'H5':
case 'H6': {
const text = inlineFrom(children(element)).replace(/\n+/g, ' ').trim();
if (!text) return '';
return '#'.repeat(Number(element.nodeName[1])) + ' ' + text;
}
case 'HR':
return '---';
case 'PRE': {
const body = element.textContent.replace(/\n$/, '');
const language = languageOf(element);
// A fence longer than any run of backticks inside, or a note about
// Markdown closes its own code block.
const longest = Math.max(2, ...[...body.matchAll(/`+/g)].map((match) => match[0].length));
const fence = '`'.repeat(longest + 1);
return fence + language + '\n' + body + '\n' + fence;
}
case 'BLOCKQUOTE': {
const inner = serializeBlocks(element).trim();
if (!inner) return '';
return inner.split('\n').map((line) => (line ? '> ' + line : '>')).join('\n');
}
case 'UL':
case 'OL':
return serializeList(element);
case 'TABLE':
return serializeTable(element);
case 'DIV':
case 'SECTION':
case 'ARTICLE':
case 'FIGURE':
// A browser's line wrapper, or a real container. Both are handled by
// asking what is inside.
return hasBlockChild(element) ? serializeBlocks(element) : paragraph(inlineFrom(children(element)));
default:
return paragraph(inlineFrom(children(element)));
}
}
function serializeList(list, depth = 0) {
const ordered = list.nodeName === 'OL';
const start = Number.parseInt(list.getAttribute('start') ?? '', 10);
let number = Number.isFinite(start) && start > 0 ? start : 1;
const out = [];
// Two columns per level, matching what the parser takes back apart.
const indent = ' '.repeat(CONTINUATION);
const shift = (block) => block.split('\n').map((line) => (line ? indent + line : '')).join('\n');
for (const item of children(list)) {
if (item.nodeType !== 1) continue;
if (item.nodeName === 'UL' || item.nodeName === 'OL') {
// A list as a *sibling* of the items rather than inside one. Several
// engines produce this when Tab indents a bullet, and skipping it
// would silently drop everything the person nested.
const nested = shift(serializeList(item, depth + 1));
if (out.length > 0) out[out.length - 1] += '\n' + nested;
else out.push(nested);
continue;
}
if (item.nodeName !== 'LI') continue;
const checkbox = firstCheckbox(item);
const marker = ordered ? number++ + '.' : '-';
const box = checkbox ? (checkbox.getAttribute('checked') === null ? '[ ] ' : '[x] ') : '';
// The item's own text, then whatever blocks hang under it.
const leading = [];
const blocks = [];
for (const child of children(item)) {
if (child === checkbox) continue;
if (child.nodeType === 1 && BLOCK_TAGS.has(child.nodeName)) blocks.push(child);
else if (blocks.length === 0) leading.push(child);
// Inline content after a nested list is rare and reads as part of it.
else blocks.push(child);
}
// An item whose text the browser wrapped in a div or a p: that is the
// item's own line, not a block underneath it.
if (leading.length === 0 && blocks.length > 0 && (blocks[0].nodeName === 'DIV' || blocks[0].nodeName === 'P')) {
if (!hasBlockChild(blocks[0])) leading.push(...children(blocks.shift()));
}
const head = paragraph(inlineFrom(leading));
const rest = blocks
.map((child) =>
child.nodeType === 1 && (child.nodeName === 'UL' || child.nodeName === 'OL')
? serializeList(child, depth + 1)
: serializeBlock(child),
)
.filter(Boolean);
const first = marker + ' ' + box + head.split('\n').join('\n' + indent);
out.push([first, ...rest.map(shift)].join('\n'));
}
return out.join('\n');
}
function serializeTable(table) {
const rows = [];
const walk = (node) => {
for (const child of children(node)) {
if (child.nodeType !== 1) continue;
if (child.nodeName === 'TR') rows.push(child);
else walk(child);
}
};
walk(table);
if (rows.length === 0) return '';
const cells = rows.map((row) =>
children(row)
.filter((cell) => cell.nodeType === 1 && (cell.nodeName === 'TD' || cell.nodeName === 'TH'))
.map((cell) => inlineFrom(children(cell)).replace(/\n+/g, ' ').replace(/\|/g, '\\|').trim()),
);
const width = Math.max(...cells.map((row) => row.length));
const line = (row) => '| ' + Array.from({ length: width }, (_, i) => row[i] ?? '').join(' | ') + ' |';
// A header row is required by the syntax: a table whose first row is data
// would otherwise lose that row entirely.
return [line(cells[0]), '|' + ' --- |'.repeat(width), ...cells.slice(1).map(line)].join('\n');
}
function inlineFrom(nodes) {
return nodes.map(inlineNode).join('');
}
function inlineNode(node) {
if (node.nodeType === 3) return escapeText(node.textContent);
if (node.nodeType !== 1) return '';
switch (node.nodeName) {
case 'BR':
return '\n';
case 'IMG':
// No note here has an image; one arriving by paste says so rather
// than vanishing.
return node.getAttribute('alt') ? '[' + escapeText(node.getAttribute('alt')) + ']' : '';
case 'INPUT':
// Only ever a task checkbox, and `serializeList` has already read it.
return '';
case 'CODE': {
const body = node.textContent;
if (!body) return '';
const longest = Math.max(0, ...[...body.matchAll(/`+/g)].map((match) => match[0].length));
const fence = '`'.repeat(longest + 1);
const pad = body.startsWith('`') || body.endsWith('`') ? ' ' : '';
return fence + pad + body + pad + fence;
}
case 'A': {
const label = inlineFrom(children(node));
const href = (node.getAttribute('href') ?? '').trim();
if (!href) return label;
if (!label.trim()) return href;
return '[' + label + '](' + href + ')';
}
case 'STRONG':
case 'B':
return emphasise(inlineFrom(children(node)), '**');
case 'EM':
case 'I':
return emphasise(inlineFrom(children(node)), '_');
case 'DEL':
case 'S':
case 'STRIKE':
return emphasise(inlineFrom(children(node)), '~~');
case 'SPAN':
case 'FONT': {
// What a paste leaves behind. The tag says nothing; the style might.
const style = node.getAttribute('style') ?? '';
const inner = inlineFrom(children(node));
if (/font-weight:\s*(bold|[6-9]00)/i.test(style)) return emphasise(inner, '**');
if (/font-style:\s*italic/i.test(style)) return emphasise(inner, '_');
return inner;
}
default:
return inlineFrom(children(node));
}
}
/** Markers hug their text: `** bold **` is four literal stars, not emphasis. */
function emphasise(inner, marker) {
const parts = /^(\s*)([\s\S]*?)(\s*)$/.exec(inner);
if (!parts[2]) return inner;
// Already carrying the same marker (nested `<b><b>`, or a paste): once is enough.
if (parts[2].startsWith(marker) && parts[2].endsWith(marker)) return inner;
return parts[1] + marker + parts[2] + marker + parts[3];
}
/**
* A run of inline content as one paragraph.
*
* Line starts are escaped here rather than in `escapeText`, because whether a
* `-` opens a list depends on where in the line it sits.
*/
function paragraph(text) {
return text
.split('\n')
.map((line) =>
line
.replace(/^(\s*)([#>]|[-*+](?=\s))/, '$1\\$2')
// The backslash goes before the dot, never before the digit: a
// backslash in front of anything but punctuation is a literal
// backslash, and `\1.` would be written into the file as it looks.
.replace(/^(\s*\d{1,9})([.)](?=\s))/, '$1\\$2'),
)
.join('\n')
.replace(/^\n+|\n+$/g, '');
}
function escapeText(value) {
return String(value)
.replace(/\\/g, '\\\\')
.replace(/([`*[\]])/g, '\\$1')
// Only where it could be read as emphasis: `snake_case` stays readable.
.replace(/(^|[^\w_])_/g, '$1\\_')
.replace(/_($|[^\w_])/g, '\\_$1')
.replace(/~~/g, '\\~\\~')
// A lone `<` only matters when it could open a tag.
.replace(/<(?=[a-zA-Z/!])/g, '\\<');
}
function languageOf(pre) {
for (const child of children(pre)) {
if (child.nodeType === 1 && child.nodeName === 'CODE') {
const match = /language-([A-Za-z0-9_+#-]+)/.exec(child.getAttribute('class') ?? '');
if (match) return match[1];
}
}
return '';
}
function firstCheckbox(item) {
for (const child of children(item)) {
if (child.nodeType === 1 && child.nodeName === 'INPUT' && child.getAttribute('type') === 'checkbox') return child;
}
return undefined;
}
function hasBlockChild(element) {
return children(element).some((child) => child.nodeType === 1 && BLOCK_TAGS.has(child.nodeName));
}
function children(node) {
return Array.prototype.slice.call(node.childNodes ?? []);
}

View File

@@ -9,13 +9,35 @@ import type { NextFunction, Request, Response } from 'express';
* the token is the only thing between a stranger and the account's data.
* Comparison is constant-time, and a miss returns a bare 401 with a
* `WWW-Authenticate` challenge and no detail about why.
*
* A route can accept more than one token: `/mcp` also takes the connector
* token claude.ai stores, which `/api` refuses.
*
* `alsoAccept` is the other kind of caller: a person logged into the web app,
* carrying a session cookie rather than a token. `/api` takes it because the
* app is built on `/api` and a session *is* the user; `/mcp` does not, because
* nothing in a browser speaks MCP and a surface not needed is a surface not
* offered.
*/
export function bearerAuth(expected: string) {
const expectedBytes = Buffer.from(expected, 'utf8');
export function bearerAuth(accepted: string | string[], alsoAccept?: (req: Request) => boolean) {
const expected = (Array.isArray(accepted) ? accepted : [accepted]).map((token) => Buffer.from(token, 'utf8'));
return function authenticate(req: Request, res: Response, next: NextFunction): void {
const presented = extractToken(req.get('authorization'), req.get('x-api-key'));
if (presented === undefined || !constantTimeEquals(Buffer.from(presented, 'utf8'), expectedBytes)) {
// A logged-in browser instead of a token. Checked first because the app's
// own fetches carry no Authorization header at all, and running them
// through the token comparison would only waste it.
if (alsoAccept?.(req)) {
next();
return;
}
const presented = extractToken(req.get('authorization'), req.get('x-api-key') ?? req.get('x-auth-token'));
// Every token is compared even after a match, so the timing does not
// tell which one was presented.
const matched =
presented !== undefined &&
expected.map((token) => constantTimeEquals(Buffer.from(presented, 'utf8'), token)).includes(true);
if (!matched) {
res.setHeader('WWW-Authenticate', 'Bearer realm="schulcloud-mcp"');
res.status(401).json({
jsonrpc: '2.0',
@@ -28,12 +50,38 @@ export function bearerAuth(expected: string) {
};
}
/**
* Gate for `/:secret/mcp`, the header-free way in.
*
* A wrong secret answers exactly like any other unknown path, so guessing
* learns nothing — not even that the route exists. The comparison is
* constant-time for the same reason as the bearer check's.
*/
export function pathSecret(expected: string) {
const expectedBytes = Buffer.from(expected, 'utf8');
return function checkPathSecret(req: Request, res: Response, next: NextFunction): void {
const presented = req.params.secret;
if (typeof presented !== 'string' || !constantTimeEquals(Buffer.from(presented, 'utf8'), expectedBytes)) {
res.status(404).json({ error: 'not_found' });
return;
}
next();
};
}
function extractToken(authorization: string | undefined, apiKey: string | undefined): string | undefined {
if (authorization) {
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
const value = authorization.trim();
const match = /^Bearer\s+(.+)$/i.exec(value);
if (match?.[1]) return match[1].trim();
// claude.ai sends a request header exactly as typed, so a token entered
// without "Bearer " arrives bare — its own docs warn most servers reject
// that. A bare credential is still the whole credential; one with another
// scheme ("Basic …") has a space in it and is not taken for one.
if (value && !/\s/.test(value)) return value;
}
// Some connector UIs only offer a custom header rather than Authorization.
// Connector UIs also offer X-Api-Key and X-Auth-Token instead of Authorization.
return apiKey?.trim() || undefined;
}

View File

@@ -6,7 +6,9 @@ import type { Config } from '../config.ts';
import { createServer } from '../mcp/server.ts';
import type { Services } from '../services.ts';
import { createApiRouter } from './api.ts';
import { bearerAuth } from './auth.ts';
import { createAppRouter } from './app-page.ts';
import { bearerAuth, pathSecret } from './auth.ts';
import { tokenPage, tokenScript } from './token-page.ts';
/**
* Streamable-HTTP front end, for use as a remote MCP connector.
@@ -56,12 +58,21 @@ export function createHttpApp(config: Config, services?: Services): express.Expr
res.json({ status: 'ok', sessions: sessions.size, index: services?.store ? 'on' : 'off' });
});
// One token guards both surfaces: the MCP endpoint and the CLI's file/manifest
// API. Splitting them was considered and rejected as unnecessary ceremony for
// a single-user deployment.
// MCP_AUTH_TOKEN opens both surfaces: the MCP endpoint and the CLI's API. The
// connector token opens /mcp alone. claude.ai stores it as a request header,
// and a credential held by a third party should reach the read-only tools,
// not /api, which can replace the Schulcloud token and stream the file mirror.
// The web app, when a password is configured. Mounted before the token gate
// so its login screen is reachable without one — it is the thing that issues
// the session everything else then accepts.
const appSurface = services ? createAppRouter(config) : undefined;
if (appSurface) app.use('/app', appSurface.router);
const loggedIn = appSurface ? (req: Request) => appSurface.auth.verify(req.get('cookie')) : undefined;
if (config.authToken) {
app.use(MCP_PATH, bearerAuth(config.authToken));
app.use(API_PATH, bearerAuth(config.authToken));
app.use(MCP_PATH, bearerAuth(config.connectorToken ? [config.authToken, config.connectorToken] : config.authToken));
app.use(API_PATH, bearerAuth(config.authToken, loggedIn));
} else {
console.warn(
'[schulcloud-mcp] MCP_AUTH_TOKEN is not set — the endpoint is UNAUTHENTICATED. ' +
@@ -71,11 +82,13 @@ export function createHttpApp(config: Config, services?: Services): express.Expr
if (services) {
app.use(API_PATH, createApiRouter(services));
// The page to paste a fresh Schulcloud token into. It holds no secret: what
// it sends goes to /api/token, behind the bearer check above.
app.get('/token', tokenPage);
app.get('/token.js', tokenScript);
}
app.use(MCP_PATH, express.json({ limit: '4mb' }));
app.post(MCP_PATH, async (req: Request, res: Response) => {
const handlePost = async (req: Request, res: Response): Promise<void> => {
const sessionId = req.get('mcp-session-id');
try {
@@ -124,7 +137,9 @@ export function createHttpApp(config: Config, services?: Services): express.Expr
console.error('[schulcloud-mcp] POST failed:', error);
if (!res.headersSent) res.status(500).json(rpcError(-32603, 'Internal server error'));
}
});
};
app.post(MCP_PATH, express.json({ limit: '4mb' }), handlePost);
// GET opens the server→client SSE stream; DELETE ends the session.
const bySession = async (req: Request, res: Response): Promise<void> => {
@@ -146,6 +161,19 @@ export function createHttpApp(config: Config, services?: Services): express.Expr
app.get(MCP_PATH, bySession);
app.delete(MCP_PATH, bySession);
// The same endpoint without a bearer token, for clients that cannot send one:
// claude.ai's connector dialog takes only a URL. The path is the credential
// here, so nothing in this server logs request paths — keep it that way — and
// the Caddy snippet redacts it from the access log. A stopgap until the
// endpoint speaks OAuth, which is what connectors are meant to use.
if (config.mcpPathSecret) {
const secretMcpPath = '/:secret/mcp';
const gate = pathSecret(config.mcpPathSecret);
app.post(secretMcpPath, gate, express.json({ limit: '4mb' }), handlePost);
app.get(secretMcpPath, gate, bySession);
app.delete(secretMcpPath, gate, bySession);
}
app.use((_req, res) => res.status(404).json({ error: 'not_found' }));
return app;

142
src/http/token-page.ts Normal file
View File

@@ -0,0 +1,142 @@
import type { Request, Response } from 'express';
/**
* `/token`: a page to paste a fresh Schulcloud token into, for when a terminal
* is not at hand. `schulcloud token set` does the same from the CLI.
*
* The page carries no secret and needs no login of its own. It sends what is
* typed into it to `PUT /api/token` with the server access token as a bearer,
* so it is exactly as protected as the API — and the server checks the pasted
* token against Schulcloud before using it.
*
* The cookie is HttpOnly, so no script on the Schulcloud page can read it and a
* one-click bookmarklet is impossible; copying it out of DevTools is the step
* that remains.
*/
const SECURITY_HEADERS = {
// The page's script is a separate file only because this policy forbids
// inline script; nothing it loads comes from anywhere else.
'Content-Security-Policy':
"default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; " +
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
'Referrer-Policy': 'no-referrer',
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'no-store',
};
const PAGE = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Schulcloud token</title>
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { margin: 0; padding: 2rem 1rem; }
main { max-width: 34rem; margin: 0 auto; }
h1 { font-size: 1.4rem; }
ol { padding-left: 1.2rem; line-height: 1.5; }
label { display: block; margin: 1rem 0 0.25rem; font-weight: 600; }
input { box-sizing: border-box; width: 100%; padding: 0.5rem; font: inherit; }
.actions { display: flex; gap: 0.5rem; margin-top: 1rem; flex-wrap: wrap; }
button { padding: 0.5rem 1rem; font: inherit; cursor: pointer; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
#result { margin-top: 1rem; min-height: 1.5em; }
.ok { color: #1a7f37; }
.error { color: #cf222e; }
@media (prefers-color-scheme: dark) { .ok { color: #3fb950; } .error { color: #f85149; } }
</style>
</head>
<body>
<main>
<h1>Replace the Schulcloud token</h1>
<ol>
<li>Open a private window and log in to Schulcloud.</li>
<li>DevTools → Application (Firefox: Storage) → Cookies → the cookie named <code>jwt</code>: copy its value.</li>
<li>Paste it below and press <em>Replace</em>. The server checks it with Schulcloud first.</li>
<li><strong>Close the private window.</strong> Left open, it logs the token out about two hours after login.</li>
</ol>
<form id="form">
<input class="visually-hidden" type="text" name="username" value="schulcloud-mcp" autocomplete="username" tabindex="-1" aria-hidden="true">
<label for="access">Server access token (MCP_AUTH_TOKEN)</label>
<input id="access" name="password" type="password" autocomplete="current-password" required>
<label for="jwt">jwt cookie</label>
<input id="jwt" type="password" autocomplete="off" spellcheck="false">
<div class="actions">
<button type="submit">Replace</button>
<button type="button" id="check">Check current token</button>
</div>
</form>
<p id="result" role="status" aria-live="polite"></p>
</main>
<script src="token.js"></script>
</body>
</html>
`;
const SCRIPT = `'use strict';
const form = document.getElementById('form');
const access = document.getElementById('access');
const jwt = document.getElementById('jwt');
const result = document.getElementById('result');
function show(text, ok) {
result.textContent = text;
result.className = ok ? 'ok' : 'error';
}
function describe(status) {
const expiry = status.expiresAt
? 'expires ' + status.expiresAt.slice(0, 10) + ' (' + status.daysLeft + ' days left)'
: 'expiry unknown';
const keepalive = status.keepalive;
const session = !keepalive ? '' : keepalive.running ? 'session alive' : 'session ended — replace the token';
return [expiry, session].filter(Boolean).join('; ');
}
async function call(method, body) {
const headers = { authorization: 'Bearer ' + access.value.trim() };
if (body) headers['content-type'] = 'application/json';
const response = await fetch('api/token', {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
cache: 'no-store',
});
const data = await response.json().catch(() => ({}));
if (response.status === 401) throw new Error('The server access token is wrong.');
if (!response.ok) throw new Error(data.message || 'HTTP ' + response.status);
return data;
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (!jwt.value.trim()) return show('Paste the jwt cookie first.', false);
show('Checking it with Schulcloud…', true);
try {
const data = await call('PUT', { jwt: jwt.value });
jwt.value = '';
const saved = data.changed && !data.persisted ? ' Not saved on the server: a restart falls back to TSC_JWT_COOKIE.' : '';
show((data.changed ? 'Replaced — ' : 'Already in use — ') + describe(data) + '.' + saved + ' Now close the private window.', true);
} catch (error) {
show(error.message, false);
}
});
document.getElementById('check').addEventListener('click', async () => {
try {
show('Current token ' + describe(await call('GET')) + '.', true);
} catch (error) {
show(error.message, false);
}
});
`;
export function tokenPage(_req: Request, res: Response): void {
res.set(SECURITY_HEADERS).type('html').send(PAGE);
}
export function tokenScript(_req: Request, res: Response): void {
res.set(SECURITY_HEADERS).type('application/javascript').send(SCRIPT);
}

197
src/http/web-auth.ts Normal file
View File

@@ -0,0 +1,197 @@
import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
import type { NextFunction, Request, Response } from 'express';
/**
* A login for the web app, as opposed to a token for a machine.
*
* Everything else here authenticates a program: the CLI and Claude send a
* bearer token they were configured with. A person on a phone cannot be asked
* to paste a 64-character token into a browser every time they want to write
* down what happened in German, so the app gets a password and a session
* cookie — which is a different credential with a different lifetime, not a
* second way to present the same one.
*
* What that buys and what it costs:
*
* - **The password is never stored, compared or logged in the clear.** It is
* put through scrypt at startup and only the hash is kept; a login hashes
* the attempt and compares in constant time.
* - **The session key is derived from the password**, so changing the password
* invalidates every session that exists — which is the behaviour anyone
* changing a password expects, and it needs no second secret and no storage.
* - **The cookie is HttpOnly and SameSite=Strict**, so no script can read it
* and no other site can cause a request that carries it. That is what stands
* in for CSRF tokens here.
* - **Login is rate-limited per address**, because the endpoint is on the
* internet and a password is guessable in a way a 32-byte token is not. The
* scrypt cost is itself a brute-force defence and, without a limiter, a
* denial-of-service vector — so the limiter is not optional.
*/
export const SESSION_COOKIE = 'sc_app';
/** How long a login lasts. Long, because the alternative is logging in during a lesson. */
const SESSION_TTL_MS = 30 * 24 * 60 * 60_000;
/** scrypt parameters. N=16384 is ~50ms here — slow enough to matter, fast enough to log in. */
const SCRYPT = { N: 16_384, r: 8, p: 1, keylen: 32 };
/** Failed logins allowed from one address before it has to wait. */
const MAX_ATTEMPTS = 8;
const ATTEMPT_WINDOW_MS = 15 * 60_000;
export interface WebAuth {
/** True when a password is configured at all; without one the app is not served. */
readonly enabled: boolean;
check(password: string, from: string): { ok: boolean; retryAfterSeconds?: number };
mint(): string;
verify(cookieHeader: string | undefined): boolean;
cookie(value: string, options: { secure: boolean }): string;
clearCookie(options: { secure: boolean }): string;
}
/**
* Builds the app's authenticator from the configured password.
*
* The salt is fixed rather than random because the hash is never stored: it
* lives in this process only, and a random salt would merely mean the same
* password produced a different session key on every restart — logging
* everyone out whenever the Pi reboots.
*/
export function createWebAuth(password: string | undefined): WebAuth {
if (!password) {
return {
enabled: false,
check: () => ({ ok: false }),
mint: () => '',
verify: () => false,
cookie: () => '',
clearCookie: () => '',
};
}
const verifier = scryptSync(password, 'schulcloud-mcp/app/verifier', SCRYPT.keylen, SCRYPT);
// A separate derivation, so a session cookie can never be used to test a
// password guess offline against the verifier.
const sessionKey = scryptSync(password, 'schulcloud-mcp/app/session', SCRYPT.keylen, SCRYPT);
const attempts = new Map<string, { count: number; first: number }>();
return {
enabled: true,
check(presented: string, from: string) {
const now = Date.now();
const record = attempts.get(from);
if (record && now - record.first > ATTEMPT_WINDOW_MS) attempts.delete(from);
const current = attempts.get(from);
if (current && current.count >= MAX_ATTEMPTS) {
return { ok: false, retryAfterSeconds: Math.ceil((ATTEMPT_WINDOW_MS - (now - current.first)) / 1000) };
}
const hashed = scryptSync(presented, 'schulcloud-mcp/app/verifier', SCRYPT.keylen, SCRYPT);
if (timingSafeEqual(hashed, verifier)) {
attempts.delete(from);
return { ok: true };
}
attempts.set(from, { count: (current?.count ?? 0) + 1, first: current?.first ?? now });
return { ok: false };
},
mint(): string {
const expires = Date.now() + SESSION_TTL_MS;
// A nonce so two logins never mint the same cookie; nothing reads it
// back, it only keeps the value unique.
const nonce = randomBytes(9).toString('base64url');
const body = `${expires}.${nonce}`;
return `${body}.${sign(body, sessionKey)}`;
},
verify(cookieHeader: string | undefined): boolean {
const value = readCookie(cookieHeader, SESSION_COOKIE);
if (!value) return false;
const cut = value.lastIndexOf('.');
if (cut <= 0) return false;
const body = value.slice(0, cut);
const presented = Buffer.from(value.slice(cut + 1), 'utf8');
const expected = Buffer.from(sign(body, sessionKey), 'utf8');
if (presented.length !== expected.length || !timingSafeEqual(presented, expected)) return false;
const expires = Number(body.split('.')[0]);
return Number.isFinite(expires) && expires > Date.now();
},
cookie(value: string, options: { secure: boolean }): string {
return [
`${SESSION_COOKIE}=${value}`,
'Path=/',
'HttpOnly',
// Strict, not Lax: nothing links into this app from elsewhere, and
// Strict is what removes cross-site requests as a category.
'SameSite=Strict',
`Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`,
options.secure ? 'Secure' : undefined,
]
.filter(Boolean)
.join('; ');
},
clearCookie(options: { secure: boolean }): string {
return [
`${SESSION_COOKIE}=`,
'Path=/',
'HttpOnly',
'SameSite=Strict',
'Max-Age=0',
options.secure ? 'Secure' : undefined,
]
.filter(Boolean)
.join('; ');
},
};
}
function sign(body: string, key: Buffer): string {
return createHmac('sha256', key).update(body).digest('base64url');
}
/** One cookie out of a `Cookie:` header, without a dependency. */
export function readCookie(header: string | undefined, name: string): string | undefined {
if (!header) return undefined;
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq === -1) continue;
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
}
return undefined;
}
/**
* True when the request reached us over TLS.
*
* Behind Caddy the hop to this process is plain HTTP, so the header it sets is
* the only evidence — and marking the cookie Secure on a connection that is
* not would make it vanish, which looks exactly like a broken login.
*/
export function isSecureRequest(req: Request): boolean {
const forwarded = req.get('x-forwarded-proto');
if (forwarded) return forwarded.split(',')[0]!.trim() === 'https';
return req.protocol === 'https';
}
/** Gate for the app's own pages and for `/api` when the caller is a browser. */
export function sessionAuth(auth: WebAuth) {
return function requireSession(req: Request, res: Response, next: NextFunction): void {
if (auth.verify(req.get('cookie'))) {
next();
return;
}
// HTML gets the login screen, fetch() gets a 401 it can act on. Answering
// a fetch with a redirect to a page would hand the caller a chunk of HTML
// it cannot use and no way to tell what went wrong.
if (req.method === 'GET' && (req.get('accept') ?? '').includes('text/html')) {
res.redirect(302, '/app/');
return;
}
res.status(401).json({ error: 'unauthorized', message: 'Log in again.' });
};
}

View File

@@ -1,10 +1,13 @@
import { mkdir, stat, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import type { Config } from '../config.ts';
import type { SchulcloudClient } from '../core/client.ts';
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';
/**
@@ -23,6 +26,12 @@ export interface IndexResult {
crawlId: number;
scope: string;
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;
@@ -46,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;
@@ -54,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 {
@@ -79,16 +96,30 @@ export class Indexer {
* while a run is in progress join it rather than starting a second.
*/
async refresh(scope: string, options: { force?: boolean } = {}): Promise<IndexResult> {
return this.start(scope, options).run;
}
/**
* Starts (or joins) a re-crawl without waiting for it.
*
* Not async on purpose: the rate-limit refusal throws synchronously, so a
* caller that answers immediately — the CLI's refresh route — can still
* report it. Waiting is the caller's choice, and holding one request open for
* a whole crawl does not survive a first crawl of the file manager: it
* downloads every course file once, and Node's fetch gives up on a response
* whose headers have not arrived after five minutes.
*/
start(scope: string, options: { force?: boolean } = {}): { run: Promise<IndexResult>; joined: boolean } {
// A full crawl covers every course, so a per-course request can ride along.
const existing = this.inFlight.get('full') ?? this.inFlight.get(scope);
if (existing) return existing.then((result) => ({ ...result, joined: true }));
if (existing) return { run: existing.then((result) => ({ ...result, joined: true })), joined: true };
const since = Date.now() - (this.lastFinishedAt.get(scope) ?? 0);
if (!options.force && since < this.minIntervalMs) {
const wait = Math.ceil((this.minIntervalMs - since) / 1000);
throw new Error(
`${scope === 'full' ? 'A full re-crawl' : `Course ${scope}`} was refreshed ${Math.round(since / 1000)}s ago. ` +
`Wait ${wait}s, or pass force to override — a full crawl is ~270 requests against Schulcloud.`,
`Wait ${wait}s, or pass force to override — a full crawl is several hundred requests against Schulcloud.`,
);
}
@@ -101,21 +132,30 @@ export class Indexer {
this.inFlight.set(scope, run);
this.runningScope = scope;
this.startedAt = new Date();
return run;
return { run, joined: false };
}
private async run(scope: string): Promise<IndexResult> {
const began = Date.now();
try {
const schoolId = (await this.client.me()).school.id;
const me = await this.client.me();
const snapshot: Snapshot = await crawl(this.client, {
schoolId,
schoolId: me.school.id,
userId: me.user.id,
courseIds: scope === 'full' ? undefined : [scope],
includeLessonContents: true,
includeFiles: true,
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);
@@ -123,6 +163,9 @@ export class Indexer {
crawlId,
scope,
courses: snapshot.courses.length,
rooms: snapshot.rooms.length,
notes: snapshot.notes.length,
lessons: snapshot.lessonLog.length,
files: snapshot.files.length,
mirrored,
extracted,
@@ -139,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.
*
@@ -186,8 +257,29 @@ export class Indexer {
return;
}
// Downloading and extracting fail for different reasons, and only the
// first is worth trying again: a timeout or a 503 is the network's, a
// PDF the parser rejects will reject again. Recording both the same way
// used to make every transient failure permanent.
let downloaded: DownloadedFile;
try {
// The file manager's ids mean nothing to files-storage; route by store.
downloaded =
file.source === 'file-manager'
? await this.client.downloadFileManagerFile(file.record.id, file.record.name)
: await this.client.downloadFile(file.record);
} catch (error) {
await this.store.recordFileText({
fileId: entry.fileId, name: entry.name, mimeType: entry.mimeType, size: entry.size,
content: null,
note: `download failed, retried on the next crawl: ${error instanceof Error ? error.message : String(error)}`,
mirrorPath: null, mirrorSize: null, retry: true,
});
skipped++;
return;
}
try {
const downloaded = await this.client.downloadFile(file.record);
const relative = paths.get(entry.fileId);
if (!relative) return;
const absolute = resolveWithin(this.config.mirrorDir, relative);
@@ -213,7 +305,7 @@ export class Indexer {
await this.store.recordFileText({
fileId: entry.fileId, name: entry.name, mimeType: entry.mimeType, size: entry.size,
content: null,
note: `download or extraction failed: ${error instanceof Error ? error.message : String(error)}`,
note: `extraction failed: ${error instanceof Error ? error.message : String(error)}`,
mirrorPath: null, mirrorSize: null,
});
skipped++;

462
src/mcp/prompts.ts Normal file
View File

@@ -0,0 +1,462 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { ErrorCode, type GetPromptResult } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import type { ServerContext } from '../context.ts';
import { addDays, germanDate, germanDay, germanWeekday, isCalendarDate, schoolToday } from '../core/dates.ts';
import { fold, joinSections, matchesAll, tokenize } from '../core/text.ts';
import { courseUri, roomUri } from './resources.ts';
import { readCourse } from './tools/content.ts';
import { readRoom } from './tools/rooms.ts';
import { ProtocolError, toProtocolError } from './tools/result.ts';
import { readTimetable } from './tools/untis.ts';
/**
* Prompts: ready-made requests a person picks from a menu, written in German
* because the people using them are at a German school.
*
* Each embeds the course's overview as a resource, so Claude starts from the
* real structure and ids instead of a name it has to look up first, and each
* says where material hides and what cannot be read — the file manager, scans
* and drafts — which is otherwise learnt one failed tool call at a time.
*/
export interface Target {
kind: 'course' | 'room';
id: string;
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.');
export function registerPrompts(server: McpServer, context: ServerContext): void {
server.registerPrompt(
'zusammenfassung',
{
title: 'Kurs zusammenfassen',
description:
'Fasst einen Kurs oder Raum aus der Schulcloud zusammen: Themen, Aufgaben und die wichtigsten ' +
'Materialien, jeweils mit Quelle.',
argsSchema: {
kurs: COURSE_ARGUMENT,
fokus: z
.string()
.optional()
.describe('Optional: worauf die Zusammenfassung eingehen soll, z. B. ein Thema. Mehrere Wörter mit _ verbinden.'),
},
},
async ({ kurs, fokus }) => {
const target = await findTarget(context, kurs);
return withOverview(
context,
target,
'Zusammenfassung',
summaryPrompt(target, argumentText(fokus), sourcesOf(context)),
);
},
);
server.registerPrompt(
'pruefungsvorbereitung',
{
title: 'Prüfungsvorbereitung',
description:
'Hilft bei der Vorbereitung auf eine Prüfung: Prüfungsstoff, Erklärungen, Übungsfragen und ein ' +
'Lernplan, auf Grundlage des Kursmaterials und des Feedbacks zu den eigenen Abgaben.',
argsSchema: {
kurs: COURSE_ARGUMENT,
thema: z
.string()
.optional()
.describe('Optional: Thema oder Stoff der Prüfung. Mehrere Wörter mit _ verbinden; ein - lässt es aus.'),
datum: z
.string()
.optional()
.describe('Optional: Tag der Prüfung, z. B. 2026-10-02. Dann gibt es einen Lernplan bis dahin.'),
},
},
async ({ kurs, thema, datum }) => {
const target = await findTarget(context, kurs);
const text = examPrompt(target, {
topic: argumentText(thema),
date: argumentText(datum),
today: germanDate(new Date()),
sources: sourcesOf(context),
});
return withOverview(context, target, 'Prüfungsvorbereitung', text);
},
);
// Only with WebUntis: without the timetable there is no way to know which
// lessons a day holds, and a "Tagesvorbereitung" that has to ask is not one.
const untis = context.untis;
if (untis) {
server.registerPrompt(
'tagesvorbereitung',
{
title: 'Tagesvorbereitung',
description:
'Bereitet einen Schultag vor: die Stunden aus WebUntis samt Entfall und Vertretung, dazu das Neue ' +
'aus den passenden Kursen der Schulcloud, Fälligkeiten und angekündigte Tests.',
argsSchema: {
tag: z
.string()
.optional()
.describe('Optional: heute (Standard), morgen, übermorgen oder ein Datum wie 2026-09-21.'),
},
},
async ({ tag }) => {
const date = resolveDay(argumentText(tag));
let timetable: string;
try {
timetable = await readTimetable(untis, { from: date, to: date });
} catch (error) {
throw toProtocolError(error, `read the WebUntis timetable for ${date}`);
}
return {
description: `Tagesvorbereitung: ${germanWeekday(date)}, ${germanDay(date)}`,
messages: [
{ role: 'user', content: { type: 'text', text: timetable } },
{ role: 'user', content: { type: 'text', text: dayPrompt(date, sourcesOf(context)) } },
],
};
},
);
}
}
/**
* The day a person meant: `heute`, `morgen`, `übermorgen`, `21.09.2026` or
* `2026-09-21`.
*
* Evening preparation is the normal case for "morgen", and a German date is
* what a German keyboard produces — refusing either would make the prompt
* something to look up rather than to type.
*/
export function resolveDay(value: string | undefined, today: string = schoolToday()): string {
const raw = value?.trim().toLowerCase();
if (!raw || raw === 'heute') return today;
if (raw === 'morgen') return addDays(today, 1);
if (raw === 'übermorgen' || raw === 'uebermorgen') return addDays(today, 2);
const german = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(raw);
if (german) return `${german[3]}-${german[2]!.padStart(2, '0')}-${german[1]!.padStart(2, '0')}`;
if (isCalendarDate(raw)) return raw;
throw new ProtocolError(
ErrorCode.InvalidParams,
`${value}“ ist kein Tag. Nimm heute, morgen, übermorgen oder ein Datum wie 2026-09-21.`,
);
}
// --- arguments -----------------------------------------------------------
/**
* An argument as a person typed it, or undefined when left out.
*
* Claude Code splits a prompt command on whitespace and drops the words that
* do not fit a named argument, so a value of several words can only arrive
* joined — `Erbrecht_und_Testament` — and a later argument can only be reached
* by filling the earlier ones, which is what `-` is for.
*/
export function argumentText(value: string | undefined): string | undefined {
const cleaned = value?.replace(/_+/g, ' ').replace(/\s+/g, ' ').trim();
return cleaned && cleaned !== '-' ? cleaned : undefined;
}
/**
* Picks the course or room a person meant.
*
* Matching is `search`'s — case- and umlaut-insensitive, every word must occur
* — so `lf07` finds "LF07 - FIA24A/B - Sb/Ha" without anyone typing the slashes.
* Looser readings only apply when a stricter one found nothing, which is what
* keeps real course names choosable: `LF1` must not be ambiguous merely because
* LF10 and LF12 exist, and "LF 11" is still found as `LF11`, since teachers
* space the same codes differently. Anything that is not a single match is
* refused with the candidates, because summarising the wrong course is worse
* than asking again.
*/
export function resolveTarget(query: string, candidates: Target[]): Target {
const wanted = query.trim();
const byId = candidates.find((candidate) => candidate.id === wanted);
if (byId) return byId;
const shown = argumentText(wanted) ?? wanted;
const terms = tokenize(wanted);
if (terms.length === 0) {
throw new ProtocolError(ErrorCode.InvalidParams, 'Gib einen Kurs oder Raum an: einen Teil des Namens oder die ID.');
}
const readings: ((name: string) => boolean)[] = [
// the whole name, word for word
(name) => tokenize(name).join(' ') === terms.join(' '),
// every word as a whole word
(name) => terms.every((term) => tokenize(name).includes(term)),
// every word as part of a word
(name) => matchesAll(name, terms),
// every word, ignoring the spaces and punctuation inside the name
(name) => terms.every((term) => fold(name).replace(/[^\p{L}\p{N}]+/gu, '').includes(term)),
];
for (const reading of readings) {
const matches = candidates.filter((candidate) => reading(candidate.name));
if (matches.length === 1) return matches[0]!;
if (matches.length > 1) {
const listed = matches
.slice(0, 10)
.map((candidate) => `${candidate.name} (${kindLabel(candidate)}, ID ${candidate.id})`)
.join('; ');
throw new ProtocolError(
ErrorCode.InvalidParams,
`${shown}“ passt auf ${matches.length} Einträge: ${listed}${matches.length > 10 ? '; …' : ''}. ` +
'Gib mehr vom Namen an (Wörter mit _ verbinden) oder die ID.',
);
}
}
const available = candidates.map((candidate) => `${candidate.name} (${kindLabel(candidate)})`).join('; ');
throw new ProtocolError(
ErrorCode.InvalidParams,
`Kein Kurs und kein Raum passt zu „${shown}“.${available ? ` Vorhanden: ${available}.` : ''}`,
);
}
function kindLabel(target: Target): string {
return target.kind === 'course' ? 'Kurs' : 'Raum';
}
async function findTarget(context: ServerContext, query: string): Promise<Target> {
let candidates: Target[];
try {
const [courses, rooms] = await Promise.all([
context.client.listAllCourses(),
// Rooms are optional here as everywhere: an account in none, or an
// instance that refuses the route, must not cost the course lookup.
context.client.listRooms().catch(() => []),
]);
candidates = [
...courses.map((course): Target => ({ kind: 'course', id: course.id, name: course.title })),
...rooms.map((room): Target => ({ kind: 'room', id: room.id, name: room.name })),
];
} catch (error) {
throw toProtocolError(error, 'list courses');
}
return resolveTarget(query, candidates);
}
async function withOverview(
context: ServerContext,
target: Target,
title: string,
instructions: string,
): Promise<GetPromptResult> {
const course = target.kind === 'course';
let overview: string;
try {
overview = course ? await readCourse(context, target.id) : await readRoom(context, target.id);
} catch (error) {
throw toProtocolError(error, `read ${target.kind} ${target.id}`);
}
return {
description: `${title}: ${target.name}`,
messages: [
{
role: 'user',
content: {
type: 'resource',
resource: { uri: course ? courseUri(target.id) : roomUri(target.id), mimeType: 'text/markdown', text: overview },
},
},
{ role: 'user', content: { type: 'text', text: instructions } },
],
};
}
// --- 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, 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.`,
`So gehst du vor:\n${numbered([
course
? 'Lies das Material hinter der Übersicht: die Boards mit get_board, die Themen mit get_lesson und die Aufgaben mit get_task.'
: 'Lies die Boards des Raums mit get_board.',
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}.`,
])}`,
`Die Zusammenfassung enthält:\n${bulleted([
`**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.',
])}`,
`Wichtig:\n${bulleted([
'Stütze dich nur auf das, was du in der Schulcloud findest, nenne jeweils die Quelle (Board, Thema, Aufgabe ' +
'oder Datei) und erfinde nichts dazu.',
UNREADABLE,
'Antworte auf Deutsch.',
])}`,
]);
}
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.',
options.topic
? `Thema der Prüfung: ${options.topic}`
: 'Das Thema der Prüfung steht noch nicht fest. Leite den wahrscheinlichen Prüfungsstoff aus dem Material ab ' +
'und gewichte die neueren Inhalte stärker.',
options.date && `Prüfungstermin: ${options.date} (heute ist ${options.today}).`,
`So gehst du vor:\n${numbered([
course
? 'Sammle den Stoff: Lies die passenden Boards (get_board), Themen (get_lesson) und Aufgaben (get_task).'
: 'Sammle den Stoff: Lies die passenden Boards des Raums mit get_board.',
course &&
`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. 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.',
UNREADABLE,
'Biete mir am Ende an, mich abzufragen.',
'Antworte auf Deutsch.',
])}`,
]);
}
/**
* The morning briefing.
*
* Built around the one thing WebUntis knows and Schulcloud does not — which
* lessons actually happen — and the one thing Schulcloud knows and WebUntis
* does not: the material for them. The two lists of things to hand in stay
* separate on purpose, because a teacher uses one or the other and a merged
* list quietly drops half.
*/
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.',
`So gehst du vor:\n${numbered([
'Geh die Stunden des Tages durch. Entfallene Stunden lässt du weg, bei Vertretungen zählt, was stattfindet.',
'Ordne jeder Stunde den passenden Kurs in der Schulcloud zu (list_courses). Die Fächerkürzel und die ' +
'Kursnamen sehen unterschiedlich aus, also geh über das Fach, nicht über eine ID.',
'Sieh dir zu jedem dieser Kurse an, was neu ist: what_changed seit dem letzten Schultag, dazu get_course ' +
'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 ' +
'meistens dort und nirgends sonst.',
])}`,
`Die Vorbereitung enthält:\n${bulleted([
'**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.',
'**Sonstiges:** Neuigkeiten (list_news), die den Tag betreffen.',
])}`,
`Wichtig:\n${bulleted([
'Wenn an dem Tag kein Unterricht ist, sag das in einem Satz, nenne den nächsten Schultag und bereite ' +
'stattdessen kurz diesen vor.',
'Stütze dich auf Schulcloud und WebUntis und nenne die Quelle. Was du aus eigenem Wissen ergänzt, ' +
'kennzeichnest du.',
UNREADABLE,
'Halte es knapp: ich lese das morgens vor der Schule.',
'Antworte auf Deutsch.',
])}`,
]);
}
function numbered(items: (string | false | undefined)[]): string {
return items
.filter((item): item is string => Boolean(item))
.map((item, index) => `${index + 1}. ${item}`)
.join('\n');
}
function bulleted(items: (string | false | undefined)[]): string {
return items
.filter((item): item is string => Boolean(item))
.map((item) => `- ${item}`)
.join('\n');
}

103
src/mcp/resources.ts Normal file
View File

@@ -0,0 +1,103 @@
import { ResourceTemplate, type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { ReadResourceResult, Resource } from '@modelcontextprotocol/sdk/types.js';
import type { ServerContext } from '../context.ts';
import { readCourse } from './tools/content.ts';
import { readRoom } from './tools/rooms.ts';
import { toProtocolError } from './tools/result.ts';
/**
* Courses and rooms as MCP resources: things a person attaches to a message,
* where tools are things the model decides to call.
*
* A resource carries exactly what get_course or get_room returns, so an
* attached course and a fetched one read the same, and the ids in it lead to
* the same tools. Deliberately coarse: a picker lists every resource at once,
* which suits some twenty-odd courses and not the thousand-odd files.
*
* The labels are German because people read them in a picker; the content
* stays the English Markdown the tools return, since the model reads that.
*/
const MARKDOWN = 'text/markdown';
export function courseUri(courseId: string): string {
return `schulcloud://courses/${courseId}`;
}
export function roomUri(roomId: string): string {
return `schulcloud://rooms/${roomId}`;
}
export function registerResources(server: McpServer, context: ServerContext): void {
server.registerResource(
'course',
new ResourceTemplate(courseUri('{courseId}'), {
list: async () => ({
resources: await listOrEmpty('courses', async () =>
(await context.client.listAllCourses()).map((course) => entry(courseUri(course.id), 'Kurs', course.title)),
),
}),
}),
{
title: 'Kurs',
description: 'Ein Kurs aus der Schulcloud: Boards, Themen, Aufgaben und Kurs-Dateien im Überblick.',
mimeType: MARKDOWN,
},
async (uri, { courseId }) => read(uri, `read course ${courseId}`, () => readCourse(context, String(courseId))),
);
server.registerResource(
'room',
new ResourceTemplate(roomUri('{roomId}'), {
list: async () => ({
resources: await listOrEmpty('rooms', async () =>
(await context.client.listRooms()).map((room) => entry(roomUri(room.id), 'Raum', room.name)),
),
}),
}),
{
title: 'Raum',
description: 'Ein Raum aus der Schulcloud: seine Boards und wer darin ist.',
mimeType: MARKDOWN,
},
async (uri, { roomId }) => read(uri, `read room ${roomId}`, () => readRoom(context, String(roomId))),
);
}
function entry(uri: string, kind: string, name: string): Resource {
return {
uri,
name,
title: name,
// Claude Code's @ autocomplete shows the description in place of the
// name, so a description without the name would leave every entry
// reading as an opaque id.
description: `${kind}: ${name}`,
mimeType: MARKDOWN,
};
}
/**
* A listing that fails yields no entries instead of an error.
*
* Every resource kind is listed in one `resources/list` reply, so one refused
* kind would otherwise cost the rest. A client may also treat a failed
* listing as a failed server and drop its tools with it — and the tools are
* where an expired token gets explained.
*/
async function listOrEmpty(kind: string, load: () => Promise<Resource[]>): Promise<Resource[]> {
try {
return await load();
} catch (error) {
console.error(`[schulcloud-mcp] resources: listing ${kind} failed:`, toProtocolError(error, `list ${kind}`).message);
return [];
}
}
async function read(uri: URL, action: string, render: () => Promise<string>): Promise<ReadResourceResult> {
try {
return { contents: [{ uri: uri.href, mimeType: MARKDOWN, text: await render() }] };
} catch (error) {
throw toProtocolError(error, action);
}
}

View File

@@ -2,13 +2,20 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { Config } from '../config.ts';
import { ServerContext } from '../context.ts';
import type { Services } from '../services.ts';
import { registerPrompts } from './prompts.ts';
import { registerResources } from './resources.ts';
import { registerContentTools } from './tools/content.ts';
import { registerFileTools } from './tools/files.ts';
import { registerFilesystemTools } from './tools/filesystem.ts';
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';
import { registerUntisTools } from './tools/untis.ts';
export const SERVER_NAME = 'schulcloud-mcp';
export const SERVER_VERSION = '0.1.0';
@@ -23,8 +30,16 @@ How the content is organised, and the usual path through it:
text block, link and attached file in one call.
- **Topics / lessons** ("Themen") — the older format. get_lesson.
- **Tasks** ("Aufgaben") — homework. list_tasks across all courses, get_task for one.
- **Quizzes and interactive exercises** are H5P elements on a board ("Quiz", "Test", "Übung"). get_board names
one and says how many questions it holds; get_h5p returns all of them with the correct options marked — the
player steps through one at a time, this does not. search finds their question text too.
- **Files** hang off boards, lessons and tasks. Every listing shows file ids; download_file fetches one and
extracts its text (PDF, Word, Excel, PowerPoint, OpenDocument) or returns an image inline.
- **The file manager ("Dateien")** is a separate store with a real folder tree, browsed with the fs_* tools:
/my (Persönliche Dateien), /courses/<course> (Kurs-Dateien), /teams/<team> (Team-Dateien) and /shared
(Geteilte Dateien). **Many teachers put their material only here**, so when a course page looks empty or the
worksheets are not on its boards, look in /courses/<course name>. fs_list and fs_tree browse, fs_find finds by
name, fs_read opens a file. list_files and download_file do not see these files.
- **Submissions** ("Abgaben") — what the user handed in. get_task shows that task's submission: the files,
the graded flag, the grade, what the user wrote, and the teacher's written feedback. A grade is a
percentage (0-100) or absent — there is no textual grade — and teachers often grade with the written
@@ -34,10 +49,32 @@ 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 — 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.
Everything here is read-only; nothing in this server can modify the account.`;
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 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);
@@ -48,11 +85,20 @@ export function createServer(config: Config, services?: Services): { server: Mcp
registerOverviewTools(server, context);
registerContentTools(server, context);
registerRoomTools(server, context);
registerFileTools(server, context);
registerFilesystemTools(server, context);
registerH5pTools(server, context);
registerSearchTool(server, context);
registerSubmissionTools(server, context);
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);
return { server, context };
}

View File

@@ -6,10 +6,14 @@ import { formatBytes } from '../../core/extract.ts';
import { dueLabel, formatDate, heading, htmlToText, joinSections, normalizeObjectId } from '../../core/text.ts';
import { assembleBoard, type AssembledBoard, type AssembledElement } from '../../core/board.ts';
import { forEachLimited } from '../../core/crawl.ts';
import { fetchLessonPadText } from '../../core/etherpad.ts';
import type { FmListing } from '../../core/legacy-files.ts';
import { fetchLessonTaskLinks, withScrapedIds } from '../../core/lesson-page.ts';
import type {
CourseBoardResponse,
CourseTime,
FileRecord,
LegacyCourse,
LessonLinkedTask,
LessonResponse,
ResolvedTask,
@@ -35,8 +39,7 @@ export function registerContentTools(server: McpServer, context: ServerContext):
},
async ({ courseId }) => {
try {
const board = await context.client.getCourseBoard(courseId);
return text(formatCourseBoard(board));
return text(await readCourse(context, courseId));
} catch (error) {
return toToolError(error, `read course ${courseId}`);
}
@@ -66,6 +69,7 @@ export function registerContentTools(server: McpServer, context: ServerContext):
const board = await assembleBoard(context.client, boardId, schoolId, {
resolveFiles: includeFiles,
resolvePads: context.config,
resolveH5p: true,
});
return text(formatBoard(board, includeFiles));
} catch (error) {
@@ -114,7 +118,19 @@ export function registerContentTools(server: McpServer, context: ServerContext):
tasks.length > 0
? withScrapedIds(tasks, await fetchLessonTaskLinks(context.config, lesson.courseId, lessonId))
: tasks;
return text(formatLesson(lesson, withIds, files?.data ?? []));
// Pads are fetched only when the topic actually has one: each costs a
// topic-page render to obtain the Etherpad session cookie.
const padTexts = new Map<number, string>();
await Promise.all(
(lesson.contents ?? []).map(async (entry, index) => {
if (entry.component !== 'Etherpad') return;
const url = entry.content?.url;
if (typeof url !== 'string') return;
const padText = await fetchLessonPadText(context.config, lesson.courseId, lessonId, url);
if (padText) padTexts.set(index, padText);
}),
);
return text(formatLesson(lesson, withIds, files?.data ?? [], padTexts));
} catch (error) {
return toToolError(error, `read lesson ${lessonId}`);
}
@@ -161,6 +177,28 @@ export function registerContentTools(server: McpServer, context: ServerContext):
);
}
/**
* A course's overview as Markdown: what get_course returns, and what the
* course resource carries, so an attached course reads the same as a fetched one.
*/
export async function readCourse(context: ServerContext, courseId: string): Promise<string> {
const [board, legacy, courseFiles] = await Promise.all([
context.client.getCourseBoard(courseId),
// The v3 projection carries no description, teachers, members or
// timetable; /api/v1/courses still does. Optional on purpose — it
// is a legacy route, so its absence must cost detail, not the call.
context.client.getLegacyCourse(courseId).catch(() => undefined),
// The course's file-manager area is a different store from the page.
// Teachers who only upload files there leave the page itself empty,
// and reporting "empty" then sends the reader away from the material.
context.files.list({ area: 'courses', ownerId: courseId }).catch(() => undefined),
]);
const teachers = legacy
? await context.resolveNames([...(legacy.teacherIds ?? []), ...(legacy.substitutionIds ?? [])])
: { names: [], unresolved: 0 };
return formatCourseBoard(board, legacy, teachers, courseFiles);
}
// --- task lookup -------------------------------------------------------
/**
@@ -238,7 +276,12 @@ async function taskFromCourse(
// --- formatting --------------------------------------------------------
function formatCourseBoard(board: CourseBoardResponse): string {
function formatCourseBoard(
board: CourseBoardResponse,
legacy?: LegacyCourse,
teachers: { names: string[]; unresolved: number } = { names: [], unresolved: 0 },
courseFiles?: FmListing,
): string {
const boards: string[] = [];
const lessons: string[] = [];
const tasks: string[] = [];
@@ -258,19 +301,101 @@ function formatCourseBoard(board: CourseBoardResponse): string {
}
}
const about = joinSections([
htmlToText(legacy?.description)?.trim() || undefined,
formatTeachers(teachers),
legacy?.userIds?.length ? `**Members:** ${legacy.userIds.length}` : undefined,
formatCourseTimes(legacy?.times),
]);
const filesSection = formatCourseFiles(board.roomId, courseFiles);
if (boards.length + lessons.length + tasks.length === 0) {
return `${heading(2, board.title)}\n\nThis course page is empty.`;
return joinSections([
heading(2, board.title),
`Course id: \`${board.roomId}\``,
about,
filesSection
? 'No boards, topics or tasks on the course page — the material is in the course files instead.'
: 'This course page is empty, and the course has no files in the file manager either.',
filesSection,
]);
}
return joinSections([
heading(2, board.title),
`Course id: \`${board.roomId}\``,
about,
boards.length > 0 && joinSections([heading(3, `Boards (${boards.length})`), boards.join('\n'), 'Read one with get_board.']),
lessons.length > 0 && joinSections([heading(3, `Topics (${lessons.length})`), lessons.join('\n'), 'Read one with get_lesson.']),
tasks.length > 0 && joinSections([heading(3, `Tasks (${tasks.length})`), tasks.join('\n'), 'Read one with get_task.']),
filesSection,
]);
}
/**
* The course's own file-manager area ("Kurs-Dateien"), when it holds anything.
*
* Only the top level is fetched — one page — so this says how much is there
* and where, rather than listing it; fs_tree does that.
*/
function formatCourseFiles(courseId: string, listing: FmListing | undefined): string | undefined {
if (!listing || listing.directories.length + listing.files.length === 0) return undefined;
const names = [...listing.directories.map((entry) => `${entry.name}/`), ...listing.files.map((entry) => entry.name)];
const shown = names.slice(0, 8).map((name) => `- ${name}`).join('\n');
return joinSections([
heading(3, 'Course files (Kurs-Dateien)'),
`${listing.directories.length} folder(s) and ${listing.files.length} file(s) at the top level, newest first:`,
shown + (names.length > 8 ? `\n- … and ${names.length - 8} more` : ''),
`See everything with fs_tree path "/courses/${courseId}", read one with fs_read.`,
]);
}
/**
* Who teaches the course.
*
* A student may not read their teachers' user records, so names are often
* unavailable; say how many there are rather than printing bare ids, which
* are no use to a reader and look like a bug.
*/
function formatTeachers(teachers: { names: string[]; unresolved: number }): string | undefined {
const { names, unresolved } = teachers;
if (names.length === 0 && unresolved === 0) return undefined;
if (names.length === 0) {
return `**Taught by:** ${unresolved} teacher(s) — names are not visible to this account`;
}
const rest = unresolved > 0 ? ` and ${unresolved} more (name not visible to this account)` : '';
return `**Taught by:** ${names.join(', ')}${rest}`;
}
/**
* A course's weekly timetable.
*
* `times` is the closest thing to a calendar the API exposes — the calendar
* service itself is not part of the v3 document. `startTime` is milliseconds
* since midnight and `weekday` is 0-based from Monday, as the legacy client
* renders it.
*/
function formatCourseTimes(times: CourseTime[] | undefined): string | undefined {
if (!times || times.length === 0) return undefined;
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const rows = times
.slice()
.sort((a, b) => (a.weekday ?? 0) - (b.weekday ?? 0) || (a.startTime ?? 0) - (b.startTime ?? 0))
.map((slot) => {
const day = days[slot.weekday ?? 0] ?? `day ${slot.weekday}`;
const room = slot.room ? `, room ${slot.room}` : '';
return `- ${day} ${clockFromMs(slot.startTime)}${slot.duration ? `${clockFromMs((slot.startTime ?? 0) + slot.duration)}` : ''}${room}`;
});
return joinSections([`**Weekly schedule:**`, rows.join('\n')]);
}
function clockFromMs(ms: number | undefined): string {
if (ms === undefined) return '?';
const total = Math.floor(ms / 60_000);
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
}
function formatBoard(board: AssembledBoard, includeFiles: boolean): string {
const columns = board.columns.map((column) => {
const cards = column.cards.map((card) => {
@@ -312,16 +437,24 @@ function formatElement(element: AssembledElement, includeFiles: boolean): string
}
case 'link': {
const label = element.text?.trim();
return element.url ? `- Link: ${label && label !== element.url ? `${label}${element.url}` : element.url}` : '';
if (!element.url) return '';
const head = `- Link: ${label && label !== element.url ? `${label}${element.url}` : element.url}`;
const note = htmlToText(element.description)?.trim();
return note ? `${head}\n${indent(note)}` : head;
}
case 'file':
case 'fileFolder':
case 'drawing': {
const caption = element.text ? ` — caption: ${element.text}` : '';
if (!includeFiles) return `- ${element.type} element \`${element.id}\`${caption}`;
// Alt text describes the picture itself, so it belongs on the line
// whether or not the file records could be listed.
const alt = element.alternativeText?.trim() ? ` — alt: ${element.alternativeText.trim()}` : '';
const note = element.description?.trim() ? `${element.description.trim()}` : '';
const extra = `${caption}${alt}${note}`;
if (!includeFiles) return `- ${element.type} element \`${element.id}\`${extra}`;
if (element.fileError) return `- ${element.type} element \`${element.id}\` — could not list files (${element.fileError})`;
if (element.files.length === 0) return `- ${element.type} element \`${element.id}\` — no files${caption}`;
return element.files.map((file) => `- ${formatFileLine(file)}${caption}`).join('\n');
if (element.files.length === 0) return `- ${element.type} element \`${element.id}\` — no files${extra}`;
return element.files.map((file) => `- ${formatFileLine(file)}${extra}`).join('\n');
}
case 'collaborativeTextEditor': {
const title = element.text ? `${element.text}` : '';
@@ -333,14 +466,42 @@ function formatElement(element: AssembledElement, includeFiles: boolean): string
}
return [`- Collaborative text document \`${element.id}\`${title}:`, indent(element.padText)].join('\n');
}
case 'externalTool':
return `- External tool${element.text ? `: ${element.text}` : ''} \`${element.id}\``;
case 'externalTool': {
// The configured-tool id is what `api_get /api/v3/tools/...` needs to
// say which tool this actually is; without it the element is opaque.
const tool = element.contextExternalToolId
? ` — configured tool \`${element.contextExternalToolId}\``
: '';
return `- External tool${element.text ? `: ${element.text}` : ''} \`${element.id}\`${tool}`;
}
case 'videoConference':
return `- Video conference \`${element.id}\``;
case 'h5p':
return `- H5P interactive content \`${element.id}\``;
case 'deleted':
return '- _(deleted element)_';
return `- Video conference${element.text ? `: ${element.text}` : ''} \`${element.id}\``;
case 'h5p': {
// Schulcloud has no quiz of its own: interactive exercises are H5P, and
// this id is the only way to reach the content behind one.
const content = element.h5pContentId ? ` \`${element.h5pContentId}\`` : '';
if (!element.h5p) {
return `- H5P interactive content \`${element.id}\`${content ? ` — H5P content${content}` : ''}`;
}
// Summarised rather than inlined: a question set runs to twenty
// questions with their options, which would bury the rest of the board.
// get_h5p prints them, and search reaches their text either way.
const quiz = element.h5p;
const kinds = [...new Set(quiz.questions.map((question) => question.kind))].join(', ');
const count = quiz.questions.length;
const unread = quiz.unmodelled.length > 0 ? `, ${quiz.unmodelled.length} part(s) this server cannot model` : '';
return (
`- **H5P exercise: ${quiz.title}** — ${count} question(s)${kinds ? ` (${kinds})` : ''}${unread}, ` +
`content${content} — all of it with get_h5p`
);
}
case 'deleted': {
// Saying what it was beats "(deleted element)": the title often names
// the material a student is looking for and cannot find.
const was = element.deletedElementType ? ` ${element.deletedElementType}` : '';
const title = element.text ? `: ${element.text}` : '';
return `- _(deleted${was} element${title})_`;
}
default:
return `- ${element.type} element \`${element.id}\``;
}
@@ -352,12 +513,17 @@ export function formatFileLine(file: FileRecord): string {
return `File: **${file.name}** (\`${file.id}\`, ${file.mimeType}, ${formatBytes(file.size)})${blocked}${pending}`;
}
function formatLesson(lesson: LessonResponse, tasks: LessonLinkedTask[], files: FileRecord[]): string {
const sections = (lesson.contents ?? []).map((entry) => {
function formatLesson(
lesson: LessonResponse,
tasks: LessonLinkedTask[],
files: FileRecord[],
padTexts: Map<number, string> = new Map(),
): string {
const sections = (lesson.contents ?? []).map((entry, index) => {
const title = entry.title?.trim();
const component = entry.component ?? 'unknown';
const hidden = entry.hidden ? ' [hidden]' : '';
const body = formatLessonComponent(component, entry.content ?? {});
const body = formatLessonComponent(component, entry.content ?? {}, padTexts.get(index));
return joinSections([heading(4, `${title || component}${hidden}`), body || `_(${component} content, nothing to show)_`]);
});
@@ -386,16 +552,40 @@ function formatLesson(lesson: LessonResponse, tasks: LessonLinkedTask[], files:
]);
}
function formatLessonComponent(component: string, content: Record<string, unknown>): string {
function formatLessonComponent(
component: string,
content: Record<string, unknown>,
padText?: string,
): string {
if (component === 'text' && typeof content.text === 'string') return htmlToText(content.text);
if (component === 'resources' && Array.isArray(content.resources)) {
return content.resources
.map((resource) => {
const entry = resource as { title?: string; url?: string; description?: string };
return `- ${entry.title ?? 'Resource'}${entry.url ? `${entry.url}` : ''}`;
const note = entry.description?.trim();
const head = `- ${entry.title ?? 'Resource'}${entry.url ? `${entry.url}` : ''}`;
return note ? `${head}\n${indent(note)}` : head;
})
.join('\n');
}
// A topic's Etherpad: the same collaborative document a column board can
// hold, reached by a stored url instead of an element id. The url is data
// and may name another deployment, in which case the text is unavailable
// and the link is the honest answer.
if (component === 'Etherpad') {
const url = typeof content.url === 'string' ? content.url : undefined;
const note = typeof content.description === 'string' ? content.description.trim() : '';
const lines = [url ? `- Collaborative text document — ${url}` : '- Collaborative text document'];
if (note) lines.push(indent(note));
if (padText) lines.push(indent(padText));
else if (url) lines.push(indent('_(empty, or its contents could not be read)_'));
return lines.join('\n');
}
// A GeoGebra applet. Only the material id is stored, so name it and let the
// reader follow it rather than rendering an empty section.
if (component === 'geoGebra' && typeof content.materialId === 'string') {
return `- GeoGebra applet \`${content.materialId}\` — https://www.geogebra.org/m/${content.materialId}`;
}
if (typeof content.url === 'string') return `- ${content.url}`;
if (typeof content.title === 'string') return content.title;
return '';

View File

@@ -2,6 +2,7 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../../context.ts';
import type { DownloadedFile } from '../../core/client.ts';
import { extractContent, formatBytes } from '../../core/extract.ts';
import { formatDate, heading, joinSections } from '../../core/text.ts';
import { FILE_PARENT_TYPES, type FileParentType } from '../../core/types.ts';
@@ -16,9 +17,11 @@ export function registerFileTools(server: McpServer, context: ServerContext): vo
{
title: 'List files of an entity',
description:
'Files attached to one entity. Most of the time you do not need this — get_board, get_lesson and ' +
'get_task already list their own attachments. Reach for it to enumerate a course\'s own file area, ' +
'or a single board element\'s files (parentType "boardnodes", parentId = the element id).',
'Attachments on one entity in files-storage: a board element, a topic, a task, a submission. Most of the ' +
'time you do not need this — get_board, get_lesson and get_task already list their own attachments. ' +
'**Not for a course\'s files, personal files, team files or shared files**: those live in the file ' +
'manager ("Dateien"), a separate store this tool cannot see — it answers 0 for a course holding dozens of ' +
'worksheets. Use fs_list, fs_tree, fs_find and fs_read for them.',
inputSchema: {
parentType: z
.enum(FILE_PARENT_TYPES as [FileParentType, ...FileParentType[]])
@@ -30,11 +33,22 @@ export function registerFileTools(server: McpServer, context: ServerContext): vo
async ({ parentType, parentId }) => {
try {
const schoolId = await context.schoolId();
const page = await context.client.listFiles({ storageLocationId: schoolId, parentType, parentId });
const [page, stats] = await Promise.all([
context.client.listFiles({ storageLocationId: schoolId, parentType, parentId }),
// Cheap, and it is the only way to see that a parent holds files
// the listing paged past.
context.client.getParentFileStats(parentType, parentId).catch(() => undefined),
]);
if (page.data.length === 0) return text(`No files attached to ${parentType} ${parentId}.`);
const total =
stats && stats.fileCount > page.data.length
? `${stats.fileCount} in total, ${formatBytes(stats.totalSizeInBytes)}`
: stats
? `${formatBytes(stats.totalSizeInBytes)} in total`
: '';
return text(
joinSections([
heading(2, `Files on ${parentType} ${parentId} (${page.data.length})`),
heading(2, `Files on ${parentType} ${parentId} (${page.data.length})${total}`),
page.data.map((file) => `- ${formatFileLine(file)} — uploaded ${formatDate(file.createdAt)}`).join('\n'),
'Read one with download_file.',
]),
@@ -50,9 +64,10 @@ export function registerFileTools(server: McpServer, context: ServerContext): vo
{
title: 'Download and read a file',
description:
'Fetches a file and returns its contents. PDFs, Word, Excel, PowerPoint and OpenDocument files are ' +
'extracted to text; images come back inline so you can look at them; anything else reports its type. ' +
'Pass raw=true to get base64 bytes instead of extracted text.',
'Fetches a board, topic or task attachment and returns its contents. PDFs, Word, Excel, PowerPoint and ' +
'OpenDocument files are extracted to text; images come back inline so you can look at them; anything ' +
'else reports its type. Pass raw=true to get base64 bytes instead of extracted text. For files from the ' +
'file manager (Persönliche Dateien, Kurs-Dateien, Team-Dateien, Geteilte Dateien) use fs_read instead.',
inputSchema: {
fileId: z.string().describe('File record id, from get_board, get_task, get_lesson or list_files.'),
raw: z
@@ -80,7 +95,12 @@ export function registerFileTools(server: McpServer, context: ServerContext): vo
`"${record.name}" was blocked by the instance's virus scanner and will not be downloaded.`,
);
}
const file = await context.client.downloadFile(record);
const [file, uploader] = await Promise.all([
context.client.downloadFile(record),
// Who put the file there is often the quickest way to tell a
// teacher's material apart from a classmate's upload.
record.creatorId ? context.userName(record.creatorId) : Promise.resolve(undefined),
]);
const header = [
heading(2, record.name),
[
@@ -88,7 +108,7 @@ export function registerFileTools(server: McpServer, context: ServerContext): vo
`- Type: ${record.mimeType}`,
`- Size: ${formatBytes(record.size)}`,
`- Attached to: ${record.parentType} \`${record.parentId}\``,
`- Uploaded: ${formatDate(record.createdAt)}`,
`- Uploaded: ${formatDate(record.createdAt)}${uploader ? ` by ${uploader}` : ''}`,
record.securityCheckStatus !== 'verified'
? `- Virus scan: ${record.securityCheckStatus}`
: undefined,
@@ -100,50 +120,99 @@ export function registerFileTools(server: McpServer, context: ServerContext): vo
.join('\n'),
].join('\n\n');
if (raw) {
return text(
joinSections([
header,
`Base64 (${file.bytes.length} bytes):`,
'```',
file.bytes.toString('base64'),
'```',
]),
);
}
const extraction = await extractContent(
file.bytes,
file.mimeType || record.mimeType,
record.name,
maxChars ?? context.config.maxExtractedChars,
);
if (extraction.kind === 'image' && extraction.image) {
const result: CallToolResult = {
content: [
{ type: 'text', text: joinSections([header, extraction.note]) },
{ type: 'image', data: extraction.image.base64, mimeType: extraction.image.mimeType },
],
};
return result;
}
if (extraction.kind === 'text') {
const body = extraction.text?.trim();
return text(
joinSections([
header,
extraction.note,
body ? joinSections([heading(3, 'Contents'), body]) : '_(the file contains no extractable text)_',
]),
);
}
return text(joinSections([header, extraction.note]));
return await renderFileContent(context, header, file, {
name: record.name,
mimeType: record.mimeType,
raw,
maxChars,
// Nothing extractable — but files-storage may still be able to render
// the file as a picture. That is the whole answer for an image-only
// PDF: its pages *are* pictures, so a rasterised preview is readable
// where the bytes are not, and it needs no OCR on our side.
fallbackImage:
record.previewStatus === 'preview_possible'
? async () => {
const preview = await context.client.getFilePreview(record, 500).catch(() => undefined);
return preview && preview.mimeType.startsWith('image/') ? preview : undefined;
}
: undefined,
});
} catch (error) {
return toToolError(error, `download file ${fileId}`);
}
},
);
}
/**
* Renders a downloaded file for a tool result: base64 when asked for, an image
* inline, extracted text, or — for a format with no extractor — its note.
*
* Shared by download_file (files-storage) and fs_read (the file manager), which
* differ only in how the bytes were obtained and in what the header says.
* `fallbackImage` is download_file's preview route; the file manager has none.
*/
export async function renderFileContent(
context: ServerContext,
header: string,
file: DownloadedFile,
options: {
name: string;
mimeType?: string;
raw: boolean;
maxChars?: number;
fallbackImage?: () => Promise<DownloadedFile | undefined>;
},
): Promise<CallToolResult> {
if (options.raw) {
return text(joinSections([header, `Base64 (${file.bytes.length} bytes):`, '```', file.bytes.toString('base64'), '```']));
}
const extraction = await extractContent(
file.bytes,
file.mimeType && file.mimeType !== 'application/octet-stream' ? file.mimeType : (options.mimeType ?? file.mimeType),
options.name,
options.maxChars ?? context.config.maxExtractedChars,
);
if (extraction.kind === 'image' && extraction.image) {
return {
content: [
{ type: 'text', text: joinSections([header, extraction.note]) },
{ type: 'image', data: extraction.image.base64, mimeType: extraction.image.mimeType },
],
};
}
if (extraction.kind === 'text') {
const body = extraction.text?.trim();
return text(
joinSections([
header,
extraction.note,
body ? joinSections([heading(3, 'Contents'), body]) : '_(the file contains no extractable text)_',
]),
);
}
const image = await options.fallbackImage?.();
if (image) {
return {
content: [
{
type: 'text',
text: joinSections([
header,
// The note ends by suggesting raw bytes, which is no longer the
// best answer once a readable rendering is attached.
extraction.note.replace(/ Use \w+ with raw=true to get the bytes\./, ''),
"Showing the instance's own rendered preview below, which is readable as a picture.",
]),
},
{ type: 'image', data: image.bytes.toString('base64'), mimeType: image.mimeType },
],
};
}
return text(joinSections([header, extraction.note]));
}

401
src/mcp/tools/filesystem.ts Normal file
View File

@@ -0,0 +1,401 @@
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../../context.ts';
import { formatBytes } from '../../core/extract.ts';
import {
areaInfo,
compareNames,
FILE_AREAS,
FileManagerMarkupError,
FsError,
nameMatcher,
type DirectoryRef,
type FmFile,
type FsNode,
type WalkEntry,
} from '../../core/legacy-files.ts';
import { heading, joinSections } from '../../core/text.ts';
import { renderFileContent } from './files.ts';
import { failure, text, toToolError } from './result.ts';
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
/**
* The "Dateien" file manager as filesystem tools: ls, tree, find, read.
*
* Deliberately separate from list_files / download_file, which read
* files-storage — board, topic and task attachments. The two stores do not
* overlap, and conflating them is how a course holding dozens of worksheets
* came to be reported as having 0 files.
*/
const AREA_NOTE =
'The file manager ("Dateien") is separate from course pages and holds four areas: ' +
'/my (Persönliche Dateien), /courses/<course name> (Kurs-Dateien), /teams/<team name> (Team-Dateien) and ' +
'/shared (Geteilte Dateien). Many teachers keep their material only in Kurs-Dateien, so a course whose page ' +
'looks empty often has its worksheets here.';
const PATH_NOTE =
'Paths use the names shown in listings, e.g. "/courses/FIA24B - LF2 (Rh)/Handlungssituation". Names may ' +
'contain "/" and still resolve; any segment may also be the id printed next to it, which is never ambiguous.';
export function registerFilesystemTools(server: McpServer, context: ServerContext): void {
server.registerTool(
'fs_list',
{
title: 'List a folder in the file manager',
description:
`Lists one folder of the Schulcloud file manager, like \`ls\`. ${AREA_NOTE} Start at "/" or ` +
'"/courses" to see what exists. Given a file path instead, shows that file\'s details. ' +
`${PATH_NOTE} Not for attachments on boards, topics or tasks — get_board, get_lesson and get_task list ` +
'those, and download_file reads them.',
inputSchema: {
path: z.string().default('/').describe('Folder to list, e.g. "/", "/courses", "/courses/<course>/<folder>".'),
},
annotations: READ_ONLY,
},
async ({ path }) => {
try {
const node = await context.files.resolve(path);
if (node.kind === 'file') return text(describeFile(node));
return text(await listDirectory(context, node));
} catch (error) {
return fsError(error, `list ${path}`);
}
},
);
server.registerTool(
'fs_tree',
{
title: 'Show a folder tree in the file manager',
description:
`Everything below a folder of the Schulcloud file manager, as an indented tree, like \`tree\`. ${AREA_NOTE} ` +
'Use it to get an overview of a course\'s files in one call — "/courses/<course>" — or of all course ' +
'files at a shallow depth. Each folder costs one page load, so the walk stops at `maxFolders` and says ' +
'so; narrow the path or lower the depth rather than raising the limit. To look for a name, fs_find is ' +
`cheaper. ${PATH_NOTE}`,
inputSchema: {
path: z.string().default('/').describe('Folder to start from.'),
depth: z.number().int().min(1).max(8).default(3).describe('How many levels below the folder to show.'),
maxFolders: z
.number()
.int()
.min(1)
.max(400)
.default(80)
.describe('Stop after listing this many folders.'),
},
annotations: READ_ONLY,
},
async ({ path, depth, maxFolders }) => {
try {
const node = await context.files.resolve(path);
if (node.kind === 'file') return text(describeFile(node));
const result = await context.files.walk(node, { maxDepth: depth, maxDirectories: maxFolders });
return text(renderTree(node, result.entries, { depth, maxFolders, ...result }));
} catch (error) {
return fsError(error, `walk ${path}`);
}
},
);
server.registerTool(
'fs_find',
{
title: 'Find files by name in the file manager',
description:
`Finds files and folders by name anywhere below a folder of the Schulcloud file manager, like \`find\`. ` +
`${AREA_NOTE} Without wildcards it matches any part of the name, case-insensitively. With "*" or "?" the ` +
'whole name must match, as with find -name — so "*.docx", or "*Erben*" for names containing Erben. Scope it with ' +
'`path` (e.g. "/courses/<course>") whenever you know the course: searching all of /courses walks every ' +
'folder of every course. This matches names only — to search inside documents use search, which ' +
`covers file-manager files once they are indexed. ${PATH_NOTE}`,
inputSchema: {
name: z
.string()
.min(1)
.describe('Part of the name ("Erbrecht"), or a whole-name pattern with * and ? ("*.docx", "*Erben*").'),
path: z.string().default('/').describe('Folder to search below. Default: every area.'),
type: z.enum(['any', 'file', 'folder']).default('any').describe('Only files, only folders, or both.'),
maxFolders: z
.number()
.int()
.min(1)
.max(600)
.default(250)
.describe('Stop after listing this many folders.'),
},
annotations: READ_ONLY,
},
async ({ name, path, type, maxFolders }) => {
try {
const node = await context.files.resolve(path);
if (node.kind === 'file') return text(describeFile(node));
const matcher = nameMatcher(name);
const result = await context.files.walk(node, { maxDepth: 12, maxDirectories: maxFolders });
const hits = result.entries
.filter((entry) => (type === 'file' ? entry.file : type === 'folder' ? entry.directory : true))
.filter((entry) => matcher((entry.file ?? entry.directory)?.name ?? ''))
.sort((a, b) => compareNames(a.path, b.path));
const scope = `${result.visited} folder(s) searched`;
const notes = [
result.truncated
? `_Stopped after ${maxFolders} folders, so there may be more matches. Narrow \`path\` to one course._`
: undefined,
failureNote(result.failures),
];
if (hits.length === 0) {
return text(joinSections([`No names matching "${name}" below ${node.path} (${scope}).`, ...notes]));
}
return text(
joinSections([
heading(2, `${hits.length} match(es) for "${name}" below ${node.path}`),
hits.map((entry) => entryLine(entry, { fullPath: true })).join('\n'),
`_${scope}._ Read a file with fs_read, open a folder with fs_list.`,
...notes,
]),
);
} catch (error) {
return fsError(error, `search ${path}`);
}
},
);
server.registerTool(
'fs_read',
{
title: 'Read a file from the file manager',
description:
`Fetches one file from the Schulcloud file manager and returns its contents, like \`cat\`. ${AREA_NOTE} ` +
'PDFs, Word, Excel, PowerPoint and OpenDocument files are extracted to text; images come back inline so ' +
'you can look at them; anything else reports its type. Pass raw=true for base64 bytes. Give the path ' +
'from a listing, or the file id and name. Not for board, topic or task attachments — use download_file ' +
`for those. ${PATH_NOTE}`,
inputSchema: {
path: z.string().optional().describe('File path, e.g. "/courses/<course>/<folder>/Arbeitsblatt.pdf".'),
fileId: z.string().optional().describe('File id from a listing, instead of a path.'),
name: z.string().optional().describe('The file name, when giving fileId; used to recognise the format.'),
raw: z.boolean().default(false).describe('Return base64-encoded bytes instead of extracted text.'),
maxChars: z
.number()
.int()
.min(500)
.max(500_000)
.optional()
.describe('Override the character limit on extracted text.'),
},
annotations: READ_ONLY,
},
async ({ path, fileId, name, raw, maxChars }) => {
try {
let file: Pick<FmFile, 'id' | 'name'> & Partial<FmFile>;
let where: string;
if (path) {
const node = await context.files.resolve(path);
if (node.kind !== 'file') {
return failure(`${node.path} is a folder, not a file. List it with fs_list, or use fs_tree.`);
}
file = node.file;
where = node.path;
} else if (fileId) {
if (!/^[0-9a-f]{24}$/i.test(fileId)) return failure(`"${fileId}" is not a file id.`);
file = { id: fileId, name: name?.trim() || fileId };
where = `file \`${fileId}\``;
} else {
return failure('Give either `path` or `fileId`.');
}
// The instance scans uploads; a file it rejected is not served.
if (file.blocked) {
return failure(`"${file.name}" was blocked by the instance's virus scanner and will not be downloaded.`);
}
const downloaded = await context.files.download(file);
const header = [
heading(2, file.name),
[
`- Path: ${where}`,
`- File id: \`${file.id}\``,
`- Type: ${file.mimeType ?? downloaded.mimeType}`,
`- Size: ${formatBytes(file.size ?? downloaded.bytes.length)}`,
downloaded.truncated
? `- **Download was capped at ${formatBytes(context.config.maxDownloadBytes)}; content is incomplete.**`
: undefined,
]
.filter(Boolean)
.join('\n'),
].join('\n\n');
return await renderFileContent(context, header, downloaded, {
name: file.name,
mimeType: file.mimeType,
raw,
maxChars,
});
} catch (error) {
return fsError(error, `read ${path ?? fileId}`);
}
},
);
}
async function listDirectory(context: ServerContext, node: Extract<FsNode, { kind: 'directory' }>): Promise<string> {
if (!node.ref.area) {
return joinSections([
heading(2, '/ — the file manager ("Dateien")'),
FILE_AREAS.map((entry) => `- **/${entry.area}/** — ${entry.label}: ${entry.summary}`).join('\n'),
'Open one with fs_list, e.g. path "/courses". A course\'s own files are under "/courses/<course name>".',
]);
}
const listing = await context.files.list(node.ref);
const area = areaInfo(node.ref.area);
const isOwnerList = (node.ref.area === 'courses' || node.ref.area === 'teams') && !node.ref.ownerId;
const directories = [...listing.directories].sort((a, b) => compareNames(a.name, b.name));
const files = [...listing.files].sort((a, b) => compareNames(a.name, b.name));
const title = heading(2, `${node.path}${area.label}`);
if (directories.length === 0 && files.length === 0) {
return joinSections([
title,
isOwnerList
? `No ${node.ref.area === 'courses' ? 'courses' : 'teams'} with a file area.`
: node.ref.area === 'shared'
? 'Nothing has been shared with you.'
: 'This folder is empty.',
]);
}
const lines = [
...directories.map((directory) => `- **${directory.name}/** (\`${directory.id}\`)`),
...files.map((file) => `- ${fileLine(file)}`),
];
const bytes = files.reduce((sum, file) => sum + file.size, 0);
const summary = isOwnerList
? `${directories.length} ${node.ref.area === 'courses' ? 'course' : 'team'}(s). Their files are inside; fs_tree with depth 2 shows which hold any.`
: `${directories.length} folder(s), ${files.length} file(s)${files.length ? `, ${formatBytes(bytes)}` : ''}.`;
return joinSections([
title,
lines.join('\n'),
summary,
node.ref.area === 'shared' && directories.length > 0
? '_Shared folders cannot be opened — the file manager has no route for them, not even in the browser._'
: undefined,
'Open a folder with fs_list (its name or id appended to this path), read a file with fs_read.',
]);
}
function describeFile(node: Extract<FsNode, { kind: 'file' }>): string {
return joinSections([
heading(2, node.file.name),
[
`- Path: ${node.path}`,
`- File id: \`${node.file.id}\``,
`- Type: ${node.file.mimeType ?? 'unknown'}`,
`- Size: ${formatBytes(node.file.size)}`,
node.file.blocked ? '- **Blocked by the instance virus scanner; it cannot be downloaded.**' : undefined,
]
.filter(Boolean)
.join('\n'),
node.file.blocked ? undefined : 'Read it with fs_read.',
]);
}
function fileLine(file: FmFile): string {
const type = file.mimeType ? `, ${file.mimeType}` : '';
const blocked = file.blocked ? ' **[blocked by virus scan]**' : '';
return `${file.name}${formatBytes(file.size)}${type} (\`${file.id}\`)${blocked}`;
}
function entryLine(entry: WalkEntry, options: { fullPath: boolean }): string {
const label = options.fullPath ? entry.path : (entry.file ?? entry.directory)?.name;
if (entry.directory) return `- **${label}/** (\`${entry.directory.id}\`)`;
if (entry.file) return `- ${fileLine({ ...entry.file, name: label ?? entry.file.name })}`;
return `- ${label}`;
}
/**
* An indented tree, built from each entry's parent rather than from sorting
* path strings — see `FileManager.walk` for why that distinction matters.
*/
function renderTree(
root: { path: string; ref: DirectoryRef },
entries: WalkEntry[],
info: { depth: number; maxFolders: number; visited: number; truncated: boolean; failures: { path: string; reason: string }[] },
): string {
const children = new Map<string, WalkEntry[]>();
for (const entry of entries) {
const list = children.get(entry.parentPath) ?? [];
list.push(entry);
children.set(entry.parentPath, list);
}
const lines: string[] = [];
let files = 0;
let folders = 0;
let bytes = 0;
const visit = (path: string, indent: string) => {
const kids = children.get(path) ?? [];
// The areas under "/" keep their own order (personal, courses, teams,
// shared); everything else sorts folders first, then by name.
if (path !== '/') {
kids.sort((a, b) => {
if (Boolean(a.directory) !== Boolean(b.directory)) return a.directory ? -1 : 1;
return compareNames(a.path, b.path);
});
}
for (const kid of kids) {
if (kid.directory) {
folders++;
// An area's "id" is its slug, not an id anything accepts; leave it out.
const id = /^[0-9a-f]{24}$/i.test(kid.directory.id) ? ` \`${kid.directory.id}\`` : '';
lines.push(`${indent}${kid.directory.name}/${id}`);
visit(kid.path, `${indent} `);
} else if (kid.file) {
files++;
bytes += kid.file.size;
const blocked = kid.file.blocked ? ' [blocked]' : '';
lines.push(`${indent}${kid.file.name} (${formatBytes(kid.file.size)}) \`${kid.file.id}\`${blocked}`);
}
}
};
visit(root.path, '');
const area = root.ref.area ? `${areaInfo(root.ref.area).label}` : '';
if (lines.length === 0) {
return joinSections([heading(2, `${root.path}${area}`), 'Nothing below this folder.', failureNote(info.failures)]);
}
return joinSections([
heading(2, `${root.path}${area}`),
['```', ...lines, '```'].join('\n'),
`${folders} folder(s), ${files} file(s), ${formatBytes(bytes)}${info.visited} folder(s) listed, ${info.depth} level(s) deep.`,
info.truncated
? `_Stopped after listing ${info.maxFolders} folders; the tree is incomplete. Start deeper, e.g. at one course._`
: undefined,
failureNote(info.failures),
'Read a file with fs_read (path = this folder plus the names above).',
]);
}
function failureNote(failures: { path: string; reason: string }[]): string | undefined {
if (failures.length === 0) return undefined;
const shown = failures.slice(0, 5).map((entry) => `${entry.path} (${entry.reason})`).join('; ');
return `_Could not list ${failures.length} folder(s): ${shown}${failures.length > 5 ? '; …' : ''}._`;
}
function fsError(error: unknown, action: string): CallToolResult {
if (error instanceof FsError) return failure(error.message);
if (error instanceof FileManagerMarkupError) {
return failure(
`Could not ${action}: the file manager page did not look like a file listing. Either the session is no ` +
'longer accepted (check whoami) or the page markup changed.',
);
}
return toToolError(error, action);
}

93
src/mcp/tools/h5p.ts Normal file
View File

@@ -0,0 +1,93 @@
/**
* The H5P tool: a quiz in full, rather than one question at a time.
*
* The board tells you an exercise exists and how many questions it holds; this
* prints them. Kept out of the board on purpose — twenty questions with their
* options would bury everything else on it.
*/
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../../context.ts';
import { heading, joinSections } from '../../core/text.ts';
import { readH5pContent, type H5pContent, type H5pQuestion } from '../../core/h5p.ts';
import { text, toToolError } from './result.ts';
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
export function registerH5pTools(server: McpServer, context: ServerContext): void {
server.registerTool(
'get_h5p',
{
title: 'Get H5P exercise',
description:
'An interactive exercise in full: every question, every option, and which of them is correct. This ' +
'is how to read a quiz ("Quiz", "Test", "Übung") that a teacher built into a board — Schulcloud has ' +
'no quiz of its own, so these are H5P elements, and get_board lists them with the content id this ' +
'takes. The player shows one question at a time; this returns all of them at once, so there is ' +
'nothing to step through. Pass solutions=false to get the questions without the answers, for asking ' +
'the user them one by one.',
inputSchema: {
contentId: z
.string()
.describe('H5P content id, as get_board prints it for an H5P element (not the element id).'),
solutions: z
.boolean()
.default(true)
.describe('Mark the correct options. Turn off to quiz the user without giving the answers away.'),
},
annotations: READ_ONLY,
},
async ({ contentId, solutions }) => {
try {
return text(formatH5p(await readH5pContent(context.client, contentId), solutions));
} catch (error) {
return toToolError(error, `read H5P content ${contentId}`);
}
},
);
}
export function formatH5p(content: H5pContent, solutions: boolean): string {
const facts = [
`- Content id: \`${content.contentId}\``,
`- Type: ${content.library}`,
`- Questions: ${content.questions.length}`,
content.passPercentage !== undefined ? `- Pass mark: ${content.passPercentage}%` : undefined,
solutions ? undefined : '- _Solutions withheld: call again with solutions=true to see them._',
].filter(Boolean);
return joinSections([
heading(2, content.title),
facts.join('\n'),
content.intro,
...content.questions.map((question, index) => formatQuestion(question, index + 1, solutions)),
content.questions.length === 0 && content.unmodelled.length === 0
? '_This exercise holds no questions this server can read._'
: undefined,
// Never silently dropped: an exercise type this server does not model
// still has its text reported, labelled for what it is.
content.unmodelled.length > 0
? joinSections([
heading(3, 'Parts this server cannot model'),
'_Read as plain text, so the structure of these is lost:_',
content.unmodelled.map((part) => `- ${part}`).join('\n'),
])
: undefined,
]);
}
function formatQuestion(question: H5pQuestion, number: number, solutions: boolean): string {
const hint = question.multiple ? ', several correct' : '';
const label = `${number}. ${question.text || '(no question text)'}`;
const options = question.answers.map((answer) => {
const tip = answer.tip ? ` _(Hinweis: ${answer.tip})_` : '';
if (!solutions || answer.correct === undefined) return `- ${answer.text}${tip}`;
return answer.correct ? `- **${answer.text}** ✔${tip}` : `- ${answer.text}${tip}`;
});
return joinSections([
heading(3, `${label} _(${question.kind}${hint})_`),
question.body,
options.length > 0 ? options.join('\n') : '_(no options)_',
]);
}

View File

@@ -6,6 +6,9 @@ import { failure, text, toToolError } from './result.ts';
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
/** How long refresh_index waits for a crawl before answering that it is still running. */
const REFRESH_WAIT_MS = 50_000;
export function registerIndexTools(server: McpServer, context: ServerContext): void {
server.registerTool(
'refresh_index',
@@ -14,8 +17,10 @@ export function registerIndexTools(server: McpServer, context: ServerContext): v
description:
'Re-reads Schulcloud and updates the local index, so search and what_changed see the newest state. ' +
'Pass a courseId when you know which course changed — that costs a handful of requests, whereas a ' +
'full re-crawl reads every course and takes up to a minute. Use it when the user says they just ' +
'uploaded or were given something and search cannot find it yet.',
'full re-crawl reads every course, every course\'s file-manager folders and every new file, and takes ' +
'minutes. A crawl that outlasts about 50 seconds keeps running in the background: this returns, and ' +
'index_status says when it is done. Use it when the user says they just uploaded or were given ' +
'something and search cannot find it yet.',
inputSchema: {
courseId: z
.string()
@@ -33,14 +38,37 @@ export function registerIndexTools(server: McpServer, context: ServerContext): v
async ({ courseId, force }) => {
if (!context.indexer) return failure(indexUnavailable('refresh_index'));
try {
const result = await context.indexer.refresh(courseId ?? 'full', { force });
const { run } = context.indexer.start(courseId ?? 'full', { force });
// A tool call must not wait out a long crawl: MCP clients time calls
// out, and a first crawl that downloads every course file runs for many
// minutes. The crawl carries on either way; the indexer records the
// outcome for index_status.
const outcome = await Promise.race([
run.then((result) => ({ done: true as const, result })),
new Promise<{ done: false }>((resolve) => setTimeout(() => resolve({ done: false }), REFRESH_WAIT_MS)),
]);
if (!outcome.done) {
run.catch(() => {});
return text(
joinSections([
heading(2, 'Re-crawl running in the background'),
`Still crawling ${courseId ? `course ${courseId}` : 'all courses'} after ${REFRESH_WAIT_MS / 1000}s. ` +
'It continues on the server; call index_status in a minute or two to see when it has finished. ' +
'Until then, search answers from the previous crawl.',
]),
);
}
const result = outcome.result;
return text(
joinSections([
heading(2, result.joined ? 'Joined a re-crawl already in progress' : 'Re-crawl complete'),
[
`- Scope: ${result.scope === 'full' ? 'all courses' : `course ${result.scope}`}`,
`- Generation: ${result.crawlId}`,
`- Courses: ${result.courses}, files: ${result.files}`,
`- 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
@@ -67,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', '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.'),
@@ -154,7 +184,14 @@ export function registerIndexTools(server: McpServer, context: ServerContext): v
const stats = await context.store.stats();
const status = context.indexer?.status();
if (stats.crawlId === undefined) {
return text('The index is empty. Run refresh_index to populate it.');
// The first crawl is the long one; "run refresh_index" while it is
// already running would only send the caller round in a circle.
return text(
status?.running
? `The first crawl is running now (started ${formatDate(status.startedAt)}). The index fills when ` +
'it finishes; the fs_* tools and the live tools work in the meantime.'
: 'The index is empty. Run refresh_index to populate it.',
);
}
const age = stats.crawledAt ? Date.now() - new Date(stats.crawledAt).getTime() : undefined;
return text(

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

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

View File

@@ -1,6 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../../context.ts';
import { decodeClaims } from '../../core/session-token.ts';
import { dueLabel, formatDate, heading, htmlToText, joinSections } from '../../core/text.ts';
import type { CourseMetadata, TaskContent } from '../../core/types.ts';
import { text, toToolError } from './result.ts';
@@ -20,6 +21,8 @@ export function registerOverviewTools(server: McpServer, context: ServerContext)
annotations: READ_ONLY,
},
async () => {
// Never fails, so the WebUntis half survives a dead Schulcloud session.
const untis = await untisLine(context);
try {
const me = await context.me();
return text(
@@ -31,11 +34,20 @@ export function registerOverviewTools(server: McpServer, context: ServerContext)
`- Roles: ${me.roles.map((role) => role.name).join(', ') || 'none'}`,
`- Instance: ${context.config.baseUrl}`,
`- Permissions: ${me.permissions.length}`,
].join('\n'),
tokenExpiryLine(context.config.jwt),
untis,
]
.filter(Boolean)
.join('\n'),
]),
);
} catch (error) {
return toToolError(error, 'read the current user');
const problem = toToolError(error, 'read the current user');
// One identity tool now answers for two systems: an expired Schulcloud
// token must not hide a working WebUntis key, or "is the server
// reachable?" gets a misleadingly total no.
if (!untis) return problem;
return { ...problem, content: [...problem.content, { type: 'text' as const, text: untis }] };
}
},
);
@@ -184,6 +196,38 @@ export function registerOverviewTools(server: McpServer, context: ServerContext)
);
}
/**
* The WebUntis side of the account, when configured.
*
* Never throws: whoami is the connectivity check, so a rejected Untis key has
* to be reported *in* the answer rather than replace it — the Schulcloud half
* of the report is still true and still useful.
*/
async function untisLine(context: ServerContext): Promise<string | undefined> {
if (!context.untis) return undefined;
try {
const me = await context.untis.identity();
return (
`- WebUntis: ${me.displayName} (${me.elementType.toLowerCase()}) at ${me.schoolName}` +
` — timetable via untis_timetable`
);
} catch (error) {
return `- WebUntis: **not reachable** — ${error instanceof Error ? error.message : String(error)}`;
}
}
/**
* When the server's Schulcloud token runs out. Only a person can renew it, so
* the week before is worth saying out loud wherever the account is shown.
*/
function tokenExpiryLine(jwt: string): string | undefined {
const exp = decodeClaims(jwt)?.exp;
if (exp === undefined) return undefined;
const days = Math.floor((exp * 1000 - Date.now()) / 86_400_000);
const renew = days <= 7 ? ' — **renew it soon** with `schulcloud token set` or the server\'s /token page' : '';
return `- Server's Schulcloud token: expires ${formatDate(new Date(exp * 1000).toISOString())} (${days} day(s) left)${renew}`;
}
function isCurrentlyRunning(course: CourseMetadata): boolean {
const now = Date.now();
const start = course.startDate ? new Date(course.startDate).getTime() : undefined;

View File

@@ -1,6 +1,25 @@
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { ErrorCode, type CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { SchulcloudApiError } from '../../core/client.ts';
/** The spec's "resource not found" code; the SDK's `ErrorCode` has no name for it. */
const RESOURCE_NOT_FOUND = -32002;
/**
* An error the SDK sends as-is: this code, exactly this message.
*
* Not McpError, whose constructor prefixes "MCP error <code>:" to the message
* — the receiving client prefixes it again, and the person reading it gets both.
*/
export class ProtocolError extends Error {
readonly code: number;
constructor(code: number, message: string) {
super(message);
this.name = 'ProtocolError';
this.code = code;
}
}
export function text(body: string): CallToolResult {
return { content: [{ type: 'text', text: body }] };
}
@@ -18,25 +37,40 @@ export function failure(body: string): CallToolResult {
* account genuinely lacks access, not that the call was malformed.
*/
export function toToolError(error: unknown, action: string): CallToolResult {
return failure(describeFailure(error, action));
}
/**
* The same diagnosis for a resource read or a prompt, which fail as protocol
* errors rather than as tool results. A 404 carries the spec's not-found code,
* which is what lets a client drop a stale resource instead of retrying it.
*/
export function toProtocolError(error: unknown, action: string): ProtocolError {
if (error instanceof ProtocolError) return error;
const notFound = error instanceof SchulcloudApiError && error.status === 404;
return new ProtocolError(notFound ? RESOURCE_NOT_FOUND : ErrorCode.InternalError, describeFailure(error, action));
}
function describeFailure(error: unknown, action: string): string {
if (error instanceof SchulcloudApiError) {
if (error.isAuthFailure) {
return failure(
return (
`Schulcloud rejected the token while trying to ${action} (HTTP 401).\n\n` +
`The JWT in TSC_JWT_COOKIE has expired or been revoked. Copy a fresh one from ` +
`the browser (DevTools → Application → Cookies → the "jwt" cookie) into the server's ` +
`environment and restart it. See docs/AUTH.md.`,
`The server's Schulcloud token has expired or been logged out. The user has to log in in a ` +
`browser, copy the "jwt" cookie (DevTools → Application → Cookies) and hand it to the server ` +
`with \`schulcloud token set\` or on the server's /token page — no restart needed. See docs/AUTH.md.`
);
}
if (error.status === 403) {
return failure(`No permission to ${action} (HTTP 403). This account cannot see that resource.`);
return `No permission to ${action} (HTTP 403). This account cannot see that resource.`;
}
if (error.status === 404) {
return failure(`Not found while trying to ${action} (HTTP 404). Check the id.`);
return `Not found while trying to ${action} (HTTP 404). Check the id.`;
}
return failure(`Failed to ${action}: ${error.message}`);
return `Failed to ${action}: ${error.message}`;
}
if (error instanceof Error && error.name === 'TimeoutError') {
return failure(`Timed out trying to ${action}. The instance may be slow or unreachable.`);
return `Timed out trying to ${action}. The instance may be slow or unreachable.`;
}
return failure(`Failed to ${action}: ${error instanceof Error ? error.message : String(error)}`);
return `Failed to ${action}: ${error instanceof Error ? error.message : String(error)}`;
}

246
src/mcp/tools/rooms.ts Normal file
View File

@@ -0,0 +1,246 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../../context.ts';
import { formatDate, heading, joinSections } from '../../core/text.ts';
import type {
RoomApplicant,
RoomBoardItem,
RoomDetails,
RoomInvitationLink,
RoomMember,
} from '../../core/types.ts';
import { text, toToolError } from './result.ts';
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
/**
* Rooms ("Räume") — the newer collaboration space, alongside courses.
*
* Worth stating plainly in the tool descriptions, because the UI's own naming
* misleads: the sidebar entry *Kurse* links to `/rooms/courses-overview` and
* lists courses, while *Räume* links to `/rooms` and lists these. A model that
* sees a `/rooms/...` url cannot tell from it which of the two it is looking at.
*/
export function registerRoomTools(server: McpServer, context: ServerContext): void {
server.registerTool(
'list_rooms',
{
title: 'List rooms',
description:
'The rooms ("Räume") this account belongs to. Rooms are a separate space from courses — they hold ' +
'column boards but no topics and no tasks. Use list_courses for courses ("Kurse"); the two are ' +
'different things despite the UI listing courses under a /rooms/... url. An empty result is normal ' +
'and simply means the account is in no rooms, which is also what happens after a teacher removes access.',
inputSchema: {},
annotations: READ_ONLY,
},
async () => {
try {
const rooms = await context.client.listRooms();
if (rooms.length === 0) {
return text(
'This account is a member of no rooms ("Räume").\n\n' +
'That is not an error — rooms are separate from courses, and membership ends when a room ' +
'is deleted or access is revoked. Course content is under list_courses.',
);
}
return text(
joinSections([
heading(2, `Rooms (${rooms.length})`),
rooms
.map((room) => {
const facts = [
room.totalMembers === undefined ? undefined : `${room.totalMembers} member(s)`,
room.isLocked ? '**locked**' : undefined,
room.endDate ? `until ${formatDate(room.endDate)}` : undefined,
].filter(Boolean);
return `- **${room.name}** (\`${room.id}\`)${facts.length > 0 ? `${facts.join(', ')}` : ''}`;
})
.join('\n'),
'Read one with get_room.',
]),
);
} catch (error) {
return toToolError(error, 'list rooms');
}
},
);
server.registerTool(
'get_room',
{
title: 'Get room',
description:
'One room and everything in it: its column boards, ready for get_board, plus who else is in it. ' +
'A room has no topics and no tasks — if you are looking for homework, that lives in courses.',
inputSchema: {
roomId: z.string().describe('Room id from list_rooms.'),
},
annotations: READ_ONLY,
},
async ({ roomId }) => {
try {
return text(await readRoom(context, roomId));
} catch (error) {
return toToolError(error, `read room ${roomId}`);
}
},
);
server.registerTool(
'list_classes',
{
title: 'List my classes and groups',
description:
'The classes ("Klassen") this account belongs to, with their teachers and size, and any other ' +
'groups it is a member of. Use it for "who is my class teacher", "which class am I in", or to ' +
'find the people behind a course. This is the only place class membership is visible — courses ' +
'and rooms do not report it.',
inputSchema: {
includeGroups: z
.boolean()
.default(false)
.describe('Also list non-class groups, such as the membership group behind each room.'),
},
annotations: READ_ONLY,
},
async ({ includeGroups }) => {
try {
const [classes, groups] = await Promise.all([
context.client.listClasses(),
includeGroups ? context.client.listGroups().catch(() => []) : Promise.resolve([]),
]);
if (classes.length === 0 && groups.length === 0) {
return text('This account is not in any class or group.');
}
const classLines = classes.map((entry) => {
const teachers = entry.teacherNames?.length ? ` — taught by ${entry.teacherNames.join(', ')}` : '';
const size = entry.studentCount === undefined ? '' : `${entry.studentCount} student(s)`;
return `- **${entry.name ?? 'Unnamed class'}** (\`${entry.id}\`)${teachers}${size}`;
});
// Room membership groups carry names and room roles, which is a second
// route to "who is in this room" when the members endpoint refuses.
const groupLines = groups
.filter((group) => group.type !== 'class')
.map((group) => {
const people = (group.users ?? [])
.map((user) => `${[user.firstName, user.lastName].filter(Boolean).join(' ')}${user.role ? ` (${user.role.replace(/^room/, '')})` : ''}`)
.filter(Boolean);
const who = people.length > 0 ? `\n${people.map((line) => ` - ${line}`).join('\n')}` : '';
return `- **${group.name ?? 'Unnamed group'}** (\`${group.id}\`, ${group.type ?? 'group'})${who}`;
});
return text(
joinSections([
classLines.length > 0
? joinSections([heading(2, `Classes (${classLines.length})`), classLines.join('\n')])
: undefined,
groupLines.length > 0
? joinSections([heading(2, `Groups (${groupLines.length})`), groupLines.join('\n')])
: undefined,
]),
);
} catch (error) {
return toToolError(error, 'list classes');
}
},
);
}
/** A room as Markdown: what get_room returns, and what the room resource carries. */
export async function readRoom(context: ServerContext, roomId: string): Promise<string> {
// Members and boards are both allowed to fail without costing the room:
// a viewer may be refused the member list, and boards can be empty.
const room = await context.client.getRoom(roomId);
// Applicants and invitation links are room-admin surface. Ask only
// when this account is allowed to, so a viewer does not pay for two
// requests that can only come back 403.
const may = room.allowedOperations ?? {};
const [boards, members, applicants, links] = await Promise.all([
context.client.listRoomBoards(roomId).catch(() => [] as RoomBoardItem[]),
context.client.listRoomMembers(roomId).catch(() => [] as RoomMember[]),
may.manageRoomApplicants
? context.client.listRoomApplicants(roomId).catch(() => [] as RoomApplicant[])
: Promise.resolve([] as RoomApplicant[]),
may.listRoomInvitationLinks
? context.client.listRoomInvitationLinks(roomId).catch(() => [] as RoomInvitationLink[])
: Promise.resolve([] as RoomInvitationLink[]),
]);
return formatRoom(room, roomId, boards, members, applicants, links);
}
function formatRoom(
room: RoomDetails,
roomId: string,
boards: RoomBoardItem[],
members: RoomMember[],
applicants: RoomApplicant[] = [],
links: RoomInvitationLink[] = [],
): string {
const name = room.name;
// Room boards report `isVisible`, so an unpublished one can be named as such
// instead of being offered and then answering 403 — which is all a course
// board can do, since the course projection omits the flag.
const boardLines = boards.map((board) => {
const draft = board.isVisible === false ? ' — **not published yet**, get_board will refuse it' : '';
return `- **${board.title}** (\`${board.id}\`)${draft}`;
});
const memberLines = members.map((member) => {
const who = [member.firstName, member.lastName].filter(Boolean).join(' ') || member.userId;
return `- ${who}${member.roomRoleName ? `${member.roomRoleName.replace(/^room/, '')}` : ''}`;
});
// What this account may do here, and which optional features the room has
// switched on. Both are already in the response and were simply dropped —
// `allowedOperations` in particular is the difference between "you are a
// viewer" and "you could edit this", which changes what to suggest next.
const granted = Object.entries(room.allowedOperations ?? {})
.filter(([, allowed]) => allowed)
.map(([operation]) => operation);
const canEdit = room.allowedOperations?.editContent === true;
const facts = [
granted.length > 0
? `- Your access: ${canEdit ? 'can edit content' : 'read-only'} (${granted.join(', ')})`
: undefined,
room.features?.length ? `- Features: ${room.features.join(', ')}` : undefined,
room.startDate || room.endDate
? `- Active: ${formatDate(room.startDate).slice(0, 10)} to ${formatDate(room.endDate).slice(0, 10)}`
: undefined,
].filter(Boolean) as string[];
return joinSections([
heading(2, name),
`Room id: \`${roomId}\``,
facts.length > 0 ? facts.join('\n') : undefined,
boardLines.length > 0
? joinSections([heading(3, `Boards (${boardLines.length})`), boardLines.join('\n'), 'Read one with get_board.'])
: '_No boards in this room._',
memberLines.length > 0 ? joinSections([heading(3, `Members (${memberLines.length})`), memberLines.join('\n')]) : undefined,
applicants.length > 0
? joinSections([
heading(3, `Waiting to join (${applicants.length})`),
applicants
.map((person) => {
const who = [person.firstName, person.lastName].filter(Boolean).join(' ') || person.userId || 'Someone';
return `- ${who}${person.schoolName ? `${person.schoolName}` : ''}`;
})
.join('\n'),
])
: undefined,
links.length > 0
? joinSections([
heading(3, `Invitation links (${links.length})`),
links
.map((link) => {
const until = link.activeUntil ? ` — until ${formatDate(link.activeUntil)}` : '';
const who = link.isOnlyForTeachers ? ' — teachers only' : '';
return `- ${link.title ?? 'Untitled link'} (\`${link.id}\`)${until}${who}`;
})
.join('\n'),
])
: undefined,
]);
}

View File

@@ -1,6 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../../context.ts';
import { FILE_AREAS } from '../../core/legacy-files.ts';
import { crawl } from '../../core/crawl.ts';
import { searchSnapshot, type Hit } from '../../core/match.ts';
import { formatDate, heading, joinSections } from '../../core/text.ts';
@@ -15,6 +16,13 @@ const TOOL_FOR: Record<string, string> = {
lesson: 'get_lesson',
task: 'get_task',
file: 'download_file',
// 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 {
@@ -27,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', '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()
@@ -113,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);
@@ -133,22 +150,83 @@ async function liveSearch(
]);
}
function targetIdFor(hit: SearchResult): string {
if (hit.kind === 'submission') {
const taskId = hit.meta?.taskId;
if (typeof taskId === 'string') return taskId;
}
return hit.nodeId;
}
/** Where to go next for a hit. File-manager files are read by path, not by download_file. */
function nextStep(hit: SearchResult): string {
if (hit.kind === 'file' && hit.meta?.source === 'file-manager') {
const fsPath = typeof hit.meta.fsPath === 'string' ? hit.meta.fsPath : undefined;
return fsPath
? `\`fs_read\` with path \`${fsPath}\``
: `\`fs_read\` with fileId \`${hit.nodeId}\` and name \`${hit.title}\``;
}
// A note is addressed by its path, not by an id — and a lesson inside a day's
// note is reached by opening the note, since `#3` is an index into this
// generation and means nothing to get_note.
if (hit.kind === 'note') {
const notePath = typeof hit.meta?.notePath === 'string' ? hit.meta.notePath : hit.nodeId.replace(/#\d+$/, '');
return `\`get_note\` with path \`${notePath}\``;
}
if (hit.kind === 'untis') {
const periodId = hit.meta?.periodId;
return `\`untis_lesson_topics\` with periodId \`${typeof periodId === 'number' ? periodId : hit.nodeId}\``;
}
// A submission has no id of its own that any tool takes: get_task is
// reached through the *task*, so point at that rather than at the
// submission id, which would simply 404.
return `\`${TOOL_FOR[hit.kind] ?? 'api_get'}\` with id \`${targetIdFor(hit)}\``;
}
/** "Kurs-Dateien, <course>" or the area's own name, from the file's fs path. */
function fileManagerPlace(hit: SearchResult): string {
const area = typeof hit.meta?.fsPath === 'string' ? hit.meta.fsPath.split('/')[1] : undefined;
const known = FILE_AREAS.find((entry) => entry.area === area);
if (!known) return 'the file manager';
return known.area === 'courses' && hit.courseTitle ? `${known.label}, ${hit.courseTitle}` : known.label;
}
/** What kind of thing a hit is, in words rather than in the store's vocabulary. */
function placeOf(hit: SearchResult): string {
if (hit.kind === 'file' && hit.meta?.source === 'file-manager') return `file in ${fileManagerPlace(hit)}`;
if (hit.kind === 'note') {
const date = typeof hit.meta?.date === 'string' ? formatDate(hit.meta.date) : undefined;
const subject = typeof hit.meta?.subject === 'string' ? hit.meta.subject : undefined;
const heading = typeof hit.meta?.heading === 'string' ? hit.meta.heading : undefined;
// Named as the user's own writing, so it is never quoted as if the school
// had published it. A lesson within a day's note says which lesson.
return ['my own note', subject ?? heading, date].filter(Boolean).join(', ');
}
if (hit.kind === 'untis') {
const date = typeof hit.meta?.date === 'string' ? formatDate(hit.meta.date) : undefined;
return ['class register (WebUntis)', date].filter(Boolean).join(', ');
}
return `${hit.kind} in ${hit.courseTitle || hit.path}`;
}
function formatIndexed(hit: SearchResult): string {
const where = placeOf(hit);
return [
`- **${hit.title}** — ${hit.kind} in ${hit.courseTitle || hit.path}`,
`- **${hit.title}** — ${where}`,
hit.snippet && hit.snippet !== hit.title ? ` ${hit.snippet}` : undefined,
`\`${TOOL_FOR[hit.kind] ?? 'api_get'}\` with id \`${hit.nodeId}\``,
nextStep(hit),
]
.filter(Boolean)
.join('\n');
}
function formatLive(hit: Hit): string {
return [
`- **${hit.courseTitle}** — ${hit.where}`,
` ${hit.snippet}`,
`\`${TOOL_FOR[hit.targetKind]}\` with id \`${hit.targetId}\``,
].join('\n');
// A note is addressed by path; everything else by id.
const next =
hit.targetKind === 'note'
? `\`get_note\` with path \`${hit.targetId}\``
: `\`${TOOL_FOR[hit.targetKind]}\` with id \`${hit.targetId}\``;
return [`- **${hit.courseTitle}** — ${hit.where}`, ` ${hit.snippet}`, next].join('\n');
}
function freshness(crawledAt: string | undefined): string {

View File

@@ -3,7 +3,7 @@ import { z } from 'zod';
import type { ServerContext } from '../../context.ts';
import { fetchLessonTaskLinks, withScrapedIds } from '../../core/lesson-page.ts';
import { forEachLimited } from '../../core/crawl.ts';
import { fetchSubmissionDetail } from '../../core/homework-page.ts';
import { fetchHomeworkPage, fetchSubmissionDetail, type SubmissionGrading } from '../../core/homework-page.ts';
import { dueLabel, heading, joinSections } from '../../core/text.ts';
import type { FileRecord, ResolvedTask, SubmissionStatus } from '../../core/types.ts';
import { formatFileLine } from './content.ts';
@@ -44,24 +44,55 @@ export function registerSubmissionTools(server: McpServer, context: ServerContex
.boolean()
.default(true)
.describe('Keep only submissions the account itself is a submitter on. Matters on teacher accounts.'),
includeFeedback: z
.boolean()
.default(false)
.describe(
'Also read each task\'s page to report the written feedback and who submitted. Answers ' +
'"what did the teacher say" in one call, but costs one extra page fetch per task — ' +
'pair it with courseId or a small limit.',
),
limit: z.number().int().min(1).max(99).default(50).describe('Maximum tasks to check.'),
},
annotations: READ_ONLY,
},
async ({ courseId, scope, onlyMine, limit }) => {
async ({ courseId, scope, onlyMine, includeFeedback, limit }) => {
try {
const [me, tasks] = await Promise.all([context.me(), collectTasks(context, scope, courseId, limit)]);
if (tasks.length === 0) return text('No tasks found to check for submissions.');
const rows: { task: ResolvedTask; status: SubmissionStatus }[] = [];
const rows: { task: ResolvedTask; status: SubmissionStatus; grading?: SubmissionGrading }[] = [];
const unavailable: string[] = [];
await forEachLimited(tasks, 5, async (task) => {
try {
const statuses = await context.client.listSubmissionStatuses(task.id);
for (const status of statuses) {
if (onlyMine && !status.submitters.includes(me.user.id)) continue;
rows.push({ task, status });
const kept = statuses.filter((status) => !onlyMine || status.submitters.includes(me.user.id));
if (kept.length === 0) return;
// Only pay for the page when asked: it is one fetch per task, and
// the status endpoint alone cannot tell feedback-only grading from
// an unmarked grade.
let grading: SubmissionGrading[] = [];
if (includeFeedback) {
const page = await fetchHomeworkPage(context.config, task.id).catch(() => undefined);
grading = page?.grading ?? [];
// The student view has no grading form; its single submission's
// feedback is still worth folding in under the same shape.
if (grading.length === 0 && page?.own && kept.length === 1 && kept[0]) {
grading = [
{
submissionId: kept[0].id,
submitterIds: kept[0].submitters,
gradeComment: page.own.gradeComment,
gradePercent: page.own.gradePercent,
},
];
}
}
for (const status of kept) {
rows.push({ task, status, grading: grading.find((g) => g.submissionId === status.id) });
}
} catch {
// A task whose submissions we cannot read is worth noting, not fatal.
@@ -79,10 +110,23 @@ export function registerSubmissionTools(server: McpServer, context: ServerContex
}
rows.sort((a, b) => Number(a.status.isGraded) - Number(b.status.isGraded));
// Resolve every submitter once. Without this a teacher's list is a
// wall of identical rows: the same task name repeated per student
// with nothing to tell them apart.
const names = new Map<string, string>();
await Promise.all(
[...new Set(rows.flatMap((row) => row.status.submitters))].map(async (id) => {
const name = await context.userName(id);
if (name) names.set(id, name);
}),
);
const formatted = await Promise.all(rows.map((row) => formatRow(row, me.user.id, names)));
return text(
joinSections([
heading(2, `Submissions (${rows.length} across ${tasks.length} task(s))`),
rows.map(formatRow).join('\n'),
formatted.join('\n'),
'Use get_task with a task id to see the submitted files and download them.',
unavailable.length > 0 ? `_Could not check ${unavailable.length} task(s)._` : undefined,
]),
@@ -126,18 +170,51 @@ export function formatGradeState(
return 'marked graded, but neither a percentage nor feedback was found';
}
function formatRow({ task, status }: { task: ResolvedTask; status: SubmissionStatus }): string {
function formatRow(
{ task, status, grading }: { task: ResolvedTask; status: SubmissionStatus; grading?: SubmissionGrading },
myUserId: string,
names: Map<string, string>,
): string {
const state = status.isSubmitted ? 'submitted' : 'not submitted';
// The list does not fetch pages, so it cannot know whether feedback exists;
// it says only what the API told it.
const graded = status.isGraded
? status.grade !== null && status.grade !== undefined
? `graded ${status.grade}%`
: 'graded (percentage not set — check get_task for written feedback)'
: 'not graded';
// With the page read, feedback-only grading can be named for what it is
// rather than deferred to get_task. Without it, say only what the API said.
const percent = status.grade ?? grading?.gradePercent;
const hasFeedback = Boolean(grading?.gradeComment);
let graded: string;
if (!status.isGraded) {
graded = 'not graded';
} else if (percent !== null && percent !== undefined) {
graded = hasFeedback ? `graded ${percent}%, with feedback` : `graded ${percent}%`;
} else if (hasFeedback) {
graded = 'graded by feedback, with no percentage given';
} else if (grading) {
graded = 'marked graded, but neither a percentage nor feedback was found';
} else {
graded = 'graded (percentage not set — pass includeFeedback for the written feedback)';
}
// Who handed it in. Omitted when the caller is the only submitter, which is
// the student case and would just be noise.
const others = status.submitters.filter((id) => id !== myUserId);
const by =
others.length > 0
? ` — by ${status.submitters.map((id) => (id === myUserId ? 'you' : (names.get(id) ?? id))).join(', ')}`
: '';
const group = status.submittingCourseGroupName ? ` — group "${status.submittingCourseGroupName}"` : '';
const course = task.courseName ? ` [${task.courseName}]` : '';
return `- **${task.name}**${course}${state}, ${graded}${group}\n task \`${task.id}\`, submission \`${status.id}\``;
const feedback = grading?.gradeComment ? `\n feedback: ${oneLine(grading.gradeComment)}` : '';
return (
`- **${task.name}**${course}${state}, ${graded}${by}${group}` +
`\n task \`${task.id}\`, submission \`${status.id}\`${feedback}`
);
}
/** Feedback is prose; keep a list row a row. */
function oneLine(value: string): string {
const collapsed = value.replace(/\s+/g, ' ').trim();
return collapsed.length > 200 ? `${collapsed.slice(0, 197)}` : collapsed;
}
async function collectTasks(
@@ -194,30 +271,44 @@ export async function describeSubmission(
context: ServerContext,
taskId: string,
): Promise<string | undefined> {
const [me, statuses, detail] = await Promise.all([
const [me, statuses, page] = await Promise.all([
context.me(),
context.client.listSubmissionStatuses(taskId).catch(() => [] as SubmissionStatus[]),
// Submitted text and written feedback exist only in the rendered web page;
// see core/homework-page.ts. Optional by construction — a failure here
// costs detail, not the whole answer.
fetchSubmissionDetail(context.config, taskId).catch(() => undefined),
fetchHomeworkPage(context.config, taskId).catch(() => undefined),
]);
if (statuses.length === 0) return undefined;
const detail = page?.own;
const mine = statuses.filter((status) => status.submitters.includes(me.user.id));
const relevant = mine.length > 0 ? mine : statuses;
const parts: string[] = [];
for (const status of relevant) {
const files = await loadSubmissionFiles(context, status.id);
const graded = formatGradeState(status, Boolean(detail?.gradeComment), detail?.gradePercent);
// On a teacher account the grade lives in the grading form, one entry per
// submission, rather than in the student's rendered feedback tab.
const grading = page?.grading.find((entry) => entry.submissionId === status.id);
const graded = formatGradeState(
status,
Boolean(detail?.gradeComment ?? grading?.gradeComment),
detail?.gradePercent ?? grading?.gradePercent,
);
const submitterNames = await context.userNamesFor(
status.submitters.filter((id) => id !== me.user.id),
);
parts.push(
[
`- Submission \`${status.id}\`${mine.length === 0 ? ' _(not yours)_' : ''}`,
`- Submission \`${status.id}\`${status.submitters.includes(me.user.id) ? '' : ' _(not yours)_'}`,
submitterNames.length > 0 ? `- Handed in by: ${submitterNames.join(', ')}` : undefined,
`- ${status.isSubmitted ? 'Submitted' : 'Not submitted'}, ${graded}`,
status.submittingCourseGroupName ? `- Group: ${status.submittingCourseGroupName}` : undefined,
status.submitters.length > 1 ? `- ${status.submitters.length} submitters` : undefined,
grading?.gradeComment && !detail?.gradeComment
? `- Feedback: ${grading.gradeComment}`
: undefined,
files.submitted.length > 0
? `- Handed in:\n${files.submitted.map((file) => ` - ${formatFileLine(file)}`).join('\n')}`
: '- No files attached to the submission',
@@ -241,11 +332,12 @@ export async function describeSubmission(
].filter(Boolean) as string[];
return joinSections([
heading(3, 'Your submission'),
// A teacher sees other people's work here, so do not call it "yours".
heading(3, mine.length > 0 ? 'Your submission' : 'Submissions'),
parts.join('\n\n'),
...written,
'Read any attachment with download_file.' +
(written.length === 0 && anyGraded(relevant)
(written.length === 0 && !page?.grading.some((entry) => entry.gradeComment) && anyGraded(relevant)
? ' No written feedback was found for this submission. It is read from the web page rather ' +
'than an API, so treat this as "not found", not as "none was given" — the teacher may ' +
'have responded on paper or in person.'

432
src/mcp/tools/untis.ts Normal file
View File

@@ -0,0 +1,432 @@
/**
* The WebUntis tools: the school day itself.
*
* Registered only when the server is configured for WebUntis, so a deployment
* without a key does not offer the model tools that can only fail.
*/
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../../context.ts';
import { addDays, daysBetween, germanDay, germanWeekday, isCalendarDate, schoolToday } from '../../core/dates.ts';
import { heading, joinSections } from '../../core/text.ts';
import {
UntisApiError,
type UntisClient,
type UntisDay,
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 };
/** How far past the requested range to look when it turns out to be empty. */
const LOOKAHEAD_DAYS = 14;
/**
* Longest range either tool will fetch.
*
* A school week is about 40 periods, so a term's worth of timetable is
* thousands of lines of Markdown — past the point where it helps anyone, and
* the answer to "what do I have" is never three months of it.
*/
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.')
.describe('A date as YYYY-MM-DD.');
export function registerUntisTools(server: McpServer, context: ServerContext): void {
const untis = context.untis;
if (!untis) return;
server.registerTool(
'untis_timetable',
{
title: 'Timetable (WebUntis)',
description:
'The lessons of a school day or a date range from WebUntis ("Stundenplan"), with cancellations ' +
'("Entfall"), substitutions ("Vertretung"), room changes, the notes teachers attach to a period — ' +
'announced tests are usually in there — plus homework due and any exam. This is the tool for "what ' +
'do I have today/tomorrow", "has anything been cancelled" and for finding which subjects to prepare. ' +
'Omit both dates for today. The material for those lessons is in Schulcloud, not here: match the ' +
'subject to a course with list_courses. Every lesson line carries its period id for untis_lesson_topics.',
inputSchema: {
from: dateArgument.optional().describe('First day. Omit for today.'),
to: dateArgument.optional().describe('Last day, inclusive. Omit for a single day.'),
changesOnly: z
.boolean()
.default(false)
.describe('Only lessons that are cancelled or changed, for "what is different this week".'),
},
annotations: READ_ONLY,
},
async ({ from, to, changesOnly }) => {
const start = from ?? schoolToday();
const end = to ?? start;
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_RANGE_DAYS) {
return failure(
`That is ${daysBetween(start, end)} days. Ask for at most ${MAX_RANGE_DAYS} at a time — a term of ` +
'timetable is thousands of lines.',
);
}
try {
return text(await readTimetable(untis, { from: start, to: end, changesOnly }));
} catch (error) {
return untisError(error, `read the timetable for ${start}${start === end ? '' : ` to ${end}`}`);
}
},
);
server.registerTool(
'untis_homework',
{
title: 'Homework (WebUntis)',
description:
'Homework recorded in WebUntis\' class register ("Hausaufgaben"), with the day it was set and the day ' +
'it is due. Separate from Schulcloud tasks ("Aufgaben") — a teacher uses one or the other, so check ' +
'both when the user asks what they have to do: list_tasks covers the Schulcloud side. Defaults to the ' +
'next two weeks.',
inputSchema: {
from: dateArgument.optional().describe('Earliest date to include. Omit for today.'),
to: dateArgument.optional().describe('Latest date to include. Omit for two weeks ahead.'),
includeCompleted: z.boolean().default(false).describe('Also list homework already ticked off.'),
},
annotations: READ_ONLY,
},
async ({ from, to, includeCompleted }) => {
const start = from ?? schoolToday();
const end = to ?? addDays(start, 14);
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_RANGE_DAYS) {
return failure(
`That is ${daysBetween(start, end)} days. Ask for at most ${MAX_RANGE_DAYS} at a time — a term of ` +
'timetable is thousands of lines.',
);
}
try {
const all = await untis.homework(start, end);
const items = includeCompleted ? all : all.filter((item) => !item.completed);
if (items.length === 0) {
return text(
`No homework in WebUntis between ${germanDay(start)} and ${germanDay(end)}` +
`${includeCompleted ? '' : ' that is still open'}. Schulcloud tasks are separate — try list_tasks.`,
);
}
return text(
joinSections([
heading(2, `Hausaufgaben ${germanDay(start)} ${germanDay(end)} (${items.length})`),
items.map(formatHomework).join('\n'),
]),
);
} catch (error) {
return untisError(error, 'read homework from WebUntis');
}
},
);
server.registerTool(
'untis_lesson_topics',
{
title: 'What was taught (WebUntis)',
description:
'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()
.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 ({ 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(
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, `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 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
* like a fetched one.
*/
export async function readTimetable(
untis: UntisClient,
options: { from: string; to: string; changesOnly?: boolean },
): Promise<string> {
const { from: start, to: end, changesOnly = false } = options;
// One request covers the range plus a lookahead, so an empty range can still
// answer "your next lessons are on …" without a second call.
const table = await untis.timetable(start, addDays(end, LOOKAHEAD_DAYS));
const asked = table.days.filter((day) => day.date <= end);
// With changesOnly a week of untouched days is noise, so only the days that
// changed are listed at all.
const shown = changesOnly
? asked
.map((day) => ({ ...day, lessons: day.lessons.filter((l) => l.cancelled || l.changed) }))
.filter((day) => day.lessons.length > 0)
: asked;
const total = shown.reduce((sum, day) => sum + day.lessons.length, 0);
// The Nachrichten des Tages belong to a single date; skip the call for a
// range, and never let it cost the timetable.
const messages = start === end && !changesOnly ? await untis.messagesOfDay(start).catch(() => []) : [];
const nextWithLessons = table.days.find((day) => day.date > end && day.lessons.length > 0);
return joinSections([
heading(2, start === end ? `${germanWeekday(start)}, ${germanDay(start)}` : `${germanDay(start)} ${germanDay(end)}`),
changesOnly && total > 0 ? '_Only cancellations and changes._' : undefined,
changesOnly && total === 0
? `_Nothing cancelled or changed${start === end ? '' : ' on any day of this range'}._`
: undefined,
...shown.map((day) => formatDay(day, start !== end)),
// Only about lessons: "your next change is on Friday" would be an odd thing
// to say, and misleading when nothing has changed.
total === 0 && !changesOnly && nextWithLessons
? `**Next lessons:** ${germanWeekday(nextWithLessons.date)}, ${germanDay(nextWithLessons.date)} ` +
`(${nextWithLessons.lessons.length} lesson(s)) — ask again with from="${nextWithLessons.date}".`
: undefined,
messages.length > 0
? joinSections([
heading(3, 'Nachrichten des Tages'),
messages.map((message) => `- ${[message.subject, message.text].filter(Boolean).join(': ')}`).join('\n'),
])
: undefined,
]);
}
// --- formatting ----------------------------------------------------------
function formatDay(day: UntisDay, withHeading: boolean): string {
const holidays = day.holidays.map((holiday) => `_${holiday.longName || holiday.name}_`).join(', ');
// A day with no lessons and no holiday is normal at a vocational school —
// the weeks in the company have no timetable — so say that rather than
// leaving a bare "nothing", which reads like a failed lookup.
const body =
day.lessons.length > 0
? day.lessons.map((lesson) => formatLesson(lesson, day.lessons)).join('\n')
: holidays
? '_No lessons._'
: '_No lessons. Not a holiday either — a company phase or a free day._';
return joinSections([
withHeading ? heading(3, `${germanWeekday(day.date)}, ${germanDay(day.date)}`) : undefined,
holidays || undefined,
body,
]);
}
/**
* One lesson as a line.
*
* A substitution arrives as two periods in the same slot — the original marked
* CANCELLED and the replacement marked IRREGULAR — rather than as one period
* with a changed teacher, so the replacement names what it stands in for by
* looking at what was cancelled beside it.
*/
function formatLesson(lesson: UntisLesson, sameDay: UntisLesson[]): string {
const names = (list: { name: string; longName?: string }[]): string => list.map((entry) => entry.name).join(', ');
const subject = lesson.subjects[0];
const subjectLabel = subject
? `**${subject.name}**${subject.longName ? ` (${subject.longName})` : ''}`
: '**(no subject)**';
const instead = [
...lesson.replaced.teachers.map((entry) => entry.name),
...(lesson.changed ? replacedBeside(lesson, sameDay) : []),
];
const status = lesson.cancelled
? ' — **Entfall**'
: lesson.changed
? ` — **Vertretung**${instead.length > 0 ? ` (statt ${[...new Set(instead)].join(', ')})` : ''}`
: '';
const room = lesson.rooms.length > 0 ? ` · Raum ${names(lesson.rooms)}` : '';
const replacedRoom = lesson.replaced.rooms.length > 0 ? ` (statt ${names(lesson.replaced.rooms)})` : '';
const teachers = lesson.teachers.length > 0 ? ` · ${lesson.teachers.map(withFullName).join(', ')}` : '';
const unknownStatus = lesson.statuses.filter((value) => !['REGULAR', 'CANCELLED', 'IRREGULAR'].includes(value));
const notes = [
lesson.exam ? `**Prüfung:** ${lesson.exam}` : undefined,
lesson.notes.info,
lesson.notes.substitution ? `Vertretungstext: ${lesson.notes.substitution}` : undefined,
lesson.notes.lesson,
...lesson.homework.map((item) => `Hausaufgabe bis ${germanDay(item.due)}: ${item.text}`),
unknownStatus.length > 0 ? `Status: ${unknownStatus.join(', ')}` : undefined,
lesson.online ? 'Online' : undefined,
].filter((note): note is string => Boolean(note));
const head =
`- ${lesson.start}${lesson.end} ${subjectLabel}${room}${replacedRoom}${teachers}${status} ` +
`\`${lesson.periodId}\``;
return notes.length > 0 ? `${head}\n${notes.map((note) => ` - ${note}`).join('\n')}` : head;
}
/** The teachers of a cancelled lesson in the same slot: who this one stands in for. */
function replacedBeside(lesson: UntisLesson, sameDay: UntisLesson[]): string[] {
return sameDay
.filter((other) => other.cancelled && other.start === lesson.start && other.periodId !== lesson.periodId)
.flatMap((other) => other.teachers.map((teacher) => teacher.name));
}
function withFullName(entry: { name: string; longName?: string }): string {
return entry.longName ? `${entry.name} (${entry.longName})` : entry.name;
}
function formatHomework(item: UntisHomework): string {
const subject = item.subject ? `${item.subject.name}${item.subject.longName ? ` (${item.subject.longName})` : ''}` : '';
const done = item.completed ? ' [erledigt]' : '';
const remark = item.remark ? `${item.remark}` : '';
const attachments = item.attachments > 0 ? ` (${item.attachments} attachment(s))` : '';
return `- **bis ${germanDay(item.due)}**${subject}: ${item.text}${remark}${attachments}${done} (set ${germanDay(item.assigned)})`;
}
/**
* WebUntis failures the user has to act on, separated from the rest.
*
* A rejected key and a wrong clock are the two that no retry fixes, and they
* are indistinguishable from "no lessons" unless they are named.
*/
function untisError(error: unknown, action: string) {
if (error instanceof UntisApiError) {
if (error.isAuthFailure) {
return failure(
`WebUntis rejected the server's key while trying to ${action}.\n\n` +
'The key from WebUntis → Profil → Freigaben → Untis Mobile has been regenerated or revoked. ' +
'Put the current one in UNTIS_SECRET and restart the server.',
);
}
if (error.isClockSkew) {
return failure(
`WebUntis refused the one-time code while trying to ${action}: the server's clock is too far off.\n\n` +
'The code is time-based, so the host needs a working NTP sync.',
);
}
}
return toToolError(error, action);
}

View File

@@ -1,5 +1,9 @@
import type { Config } from './config.ts';
import { SchulcloudClient } from './core/client.ts';
import type { SessionKeepalive } from './core/keepalive.ts';
import { FileManager } from './core/legacy-files.ts';
import { SessionToken } from './core/session-token.ts';
import { UntisClient } from './core/untis.ts';
import { Indexer } from './indexer/indexer.ts';
import { Store } from './store/store.ts';
@@ -14,14 +18,42 @@ import { Store } from './store/store.ts';
export interface Services {
config: Config;
client: SchulcloudClient;
/**
* The "Dateien" file manager. Process-wide so its short listing cache is
* shared: an `ls` in one MCP session and a `schulcloud fs get` from the CLI
* then cost one page fetch between them, not two.
*/
files: FileManager;
store: Store | undefined;
indexer: Indexer | undefined;
/** The Schulcloud token, which `/api/token` can replace without a restart. */
session: SessionToken;
/**
* WebUntis, when configured. Process-wide so its master data — 140 subjects,
* 216 teachers, every holiday of the school year — is fetched once rather
* than per MCP session.
*/
untis: UntisClient | undefined;
/**
* Set by the entry point that runs one, for status reports. Created there
* rather than here because each entry point logs to a different stream.
*/
keepalive?: SessionKeepalive;
}
export async function createServices(config: Config): Promise<Services> {
const client = new SchulcloudClient(config);
// Before anything else reads config.jwt: a token replaced at runtime and
// saved may be newer than the one in the environment.
const session = new SessionToken(config, client, config.stateDir);
await session.load();
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(
@@ -29,7 +61,7 @@ export async function createServices(config: Config): Promise<Services> {
'/files, /manifest and refresh_index are unavailable. Set DATABASE_URL to enable them.',
);
}
return { config, client, store, indexer };
return { config, client, files, store, indexer, session, untis };
}
export async function closeServices(services: Services): Promise<void> {

View File

@@ -24,7 +24,7 @@ CREATE INDEX IF NOT EXISTS crawls_finished_idx ON crawls (finished_at DESC) WHER
-- complete picture and any two can be diffed directly.
CREATE TABLE IF NOT EXISTS nodes (
crawl_id BIGINT NOT NULL REFERENCES crawls(id) ON DELETE CASCADE,
kind TEXT NOT NULL, -- course | board | lesson | task | file
kind TEXT NOT NULL, -- course | room | board | lesson | task | file
node_id TEXT NOT NULL, -- Schulcloud id
course_id TEXT,
title TEXT NOT NULL DEFAULT '',

View File

@@ -1,6 +1,8 @@
import { createHash } from 'node:crypto';
import type { CrawledFile, Snapshot } from '../core/crawl.ts';
import { noteSearchText, noteSections } from '../core/notes.ts';
import { mirrorPath } from '../core/paths.ts';
import { lessonLogText } from '../core/untis-history.ts';
import { connect, migrate, type Db } from './db.ts';
/**
@@ -14,7 +16,23 @@ import { connect, migrate, type Db } from './db.ts';
* Identity diffing also gives deletions for free, which no timestamp scheme can.
*/
export type NodeKind = 'course' | 'board' | 'lesson' | 'task' | 'file';
/**
* `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;
@@ -36,6 +54,8 @@ export interface SearchResult {
path: string;
snippet: string;
rank: number;
/** The node's stored metadata — a submission carries its `taskId` here. */
meta?: Record<string, unknown>;
}
export interface DiffResult {
@@ -227,22 +247,29 @@ export class Store {
const kinds = options.kinds ?? null;
const fts = await this.db.query<SearchRow>(
`SELECT kind, node_id, course_id, course_title, title, path,
ts_rank(fts, q) AS rank,
ts_headline('german', coalesce(nullif(body, ''), title), q,
// `meta` lives on nodes, not on search_docs; joined rather than
// duplicated so the two cannot drift apart.
`SELECT d.kind, d.node_id, d.course_id, d.course_title, d.title, d.path, n.meta,
ts_rank(d.fts, q) AS rank,
ts_headline('german', coalesce(nullif(d.body, ''), d.title), q,
'MaxWords=32, MinWords=8, MaxFragments=1, StartSel=**, StopSel=**') AS snippet
FROM search_docs, websearch_to_tsquery('german', $2) q
WHERE crawl_id = $1 AND fts @@ q AND ($3::text[] IS NULL OR kind = ANY($3))
FROM search_docs d
LEFT JOIN nodes n
ON n.crawl_id = d.crawl_id AND n.kind = d.kind AND n.node_id = d.node_id,
websearch_to_tsquery('german', $2) q
WHERE d.crawl_id = $1 AND d.fts @@ q AND ($3::text[] IS NULL OR d.kind = ANY($3))
ORDER BY rank DESC LIMIT $4`,
[crawlId, query, kinds, limit],
);
const trgm = await this.db.query<SearchRow>(
`SELECT kind, node_id, course_id, course_title, title, path,
similarity(title, $2) AS rank,
title AS snippet
FROM search_docs
WHERE crawl_id = $1 AND title %> $2 AND ($3::text[] IS NULL OR kind = ANY($3))
`SELECT d.kind, d.node_id, d.course_id, d.course_title, d.title, d.path, n.meta,
similarity(d.title, $2) AS rank,
d.title AS snippet
FROM search_docs d
LEFT JOIN nodes n
ON n.crawl_id = d.crawl_id AND n.kind = d.kind AND n.node_id = d.node_id
WHERE d.crawl_id = $1 AND d.title %> $2 AND ($3::text[] IS NULL OR d.kind = ANY($3))
ORDER BY rank DESC LIMIT $4`,
[crawlId, query, kinds, limit],
);
@@ -262,6 +289,7 @@ export class Store {
path: row.path,
snippet: (row.snippet ?? '').replace(/\s+/g, ' ').trim(),
rank: Number(row.rank),
meta: (row.meta ?? undefined) as Record<string, unknown> | undefined,
});
}
return merged.sort((a, b) => b.rank - a.rank).slice(0, limit);
@@ -337,7 +365,14 @@ export class Store {
const { rows } = await this.db.query<{ node_id: string; title: string; meta: Record<string, unknown> }>(
`SELECT n.node_id, n.title, n.meta FROM nodes n
WHERE n.crawl_id = $1 AND n.kind = 'file'
AND NOT EXISTS (SELECT 1 FROM file_texts f WHERE f.file_id = n.node_id AND f.extracted_at IS NOT NULL)`,
AND NOT EXISTS (
SELECT 1 FROM file_texts f
WHERE f.file_id = n.node_id AND f.extracted_at IS NOT NULL
-- Recorded before transient and permanent failures were told
-- apart, under one note; each gets one more attempt, after which
-- it is re-recorded under the right kind.
AND coalesce(f.extract_note, '') NOT LIKE 'download or extraction failed%'
)`,
[crawlId],
);
return rows.map((row) => ({
@@ -357,24 +392,34 @@ export class Store {
note: string;
mirrorPath: string | null;
mirrorSize: number | null;
/** A transient failure: leave the file queued so the next crawl tries again. */
retry?: boolean;
}): Promise<void> {
// Postgres text cannot hold NUL, and PDF text extraction produces it: two
// live worksheets failed to store with "invalid byte sequence for encoding
// UTF8: 0x00" and were unsearchable. Stripped here, at the boundary, so no
// extractor can reintroduce it.
const content = entry.content === null ? null : stripNul(entry.content);
const note = stripNul(entry.note);
await this.db.query(
`INSERT INTO file_texts (file_id, name, mime_type, size, content, extract_note, extracted_at, mirror_path, mirror_size, mirrored_at)
VALUES ($1,$2,$3,$4,$5,$6, now(), $7,$8, CASE WHEN $7::text IS NULL THEN NULL ELSE now() END)
VALUES ($1,$2,$3,$4,$5,$6, CASE WHEN $9::boolean THEN NULL ELSE now() END, $7,$8, CASE WHEN $7::text IS NULL THEN NULL ELSE now() END)
ON CONFLICT (file_id) DO UPDATE SET
content = EXCLUDED.content, extract_note = EXCLUDED.extract_note, extracted_at = now(),
content = EXCLUDED.content, extract_note = EXCLUDED.extract_note,
extracted_at = CASE WHEN $9::boolean THEN NULL ELSE now() END,
mirror_path = COALESCE(EXCLUDED.mirror_path, file_texts.mirror_path),
mirror_size = COALESCE(EXCLUDED.mirror_size, file_texts.mirror_size),
mirrored_at = CASE WHEN EXCLUDED.mirror_path IS NULL THEN file_texts.mirrored_at ELSE now() END`,
[
entry.fileId,
entry.name,
stripNul(entry.name),
entry.mimeType,
entry.size,
entry.content,
entry.note,
content,
note,
entry.mirrorPath,
entry.mirrorSize,
entry.retry === true,
],
);
// Make the newly extracted text searchable in the current generation.
@@ -382,10 +427,35 @@ export class Store {
`UPDATE search_docs SET body = $2
WHERE kind = 'file' AND node_id = $1
AND crawl_id = (SELECT id FROM crawls WHERE status = 'ok' ORDER BY id DESC LIMIT 1)`,
[entry.fileId, entry.content ?? ''],
[entry.fileId, content ?? ''],
);
}
/**
* Where a file's bytes live, from the latest crawl.
*
* The file manager and files-storage use the same id shape but not the same
* ids, so a live fetch has to know which one to ask.
*/
async fileSource(
fileId: string,
): Promise<{ source: 'files-storage' | 'file-manager'; name: string; mimeType: string; size: number } | undefined> {
const crawlId = await this.latestCrawlId();
if (crawlId === undefined) return undefined;
const { rows } = await this.db.query<{ title: string; meta: Record<string, unknown> }>(
`SELECT title, meta FROM nodes WHERE crawl_id = $1 AND kind = 'file' AND node_id = $2`,
[crawlId, fileId],
);
const row = rows[0];
if (!row) return undefined;
return {
source: row.meta.source === 'file-manager' ? 'file-manager' : 'files-storage',
name: row.title,
mimeType: typeof row.meta.mimeType === 'string' ? row.meta.mimeType : 'application/octet-stream',
size: typeof row.meta.size === 'number' ? row.meta.size : 0,
};
}
async mirrorEntry(fileId: string): Promise<{ path: string; size: number; name: string; mimeType: string } | undefined> {
const { rows } = await this.db.query<{ mirror_path: string | null; mirror_size: string | null; name: string; mime_type: string | null }>(
`SELECT mirror_path, mirror_size, name, mime_type FROM file_texts WHERE file_id = $1`,
@@ -446,6 +516,7 @@ interface SearchRow {
path: string;
snippet: string | null;
rank: string;
meta: Record<string, unknown> | null;
}
function toNode(row: NodeRow): StoredNode {
@@ -521,6 +592,144 @@ export function snapshotToNodes(snapshot: Snapshot): StoredNode[] {
}
}
// Submissions, when the crawl was asked for them. The digest deliberately
// includes the grade and the feedback: re-grading a submission changes
// neither its id nor its text, so without them what_changed would never
// report the one event a student actually waits for.
for (const submission of snapshot.submissions ?? []) {
nodes.push({
kind: 'submission',
nodeId: submission.id,
courseId: submission.courseId,
title: submission.taskName,
body: [submission.submittedText, submission.gradeComment].filter(Boolean).join('\n\n'),
path: `${submission.courseTitle}/${submission.taskName}`,
meta: {
taskId: submission.taskId,
isSubmitted: submission.isSubmitted,
isGraded: submission.isGraded,
grade: submission.grade ?? null,
hasFeedback: Boolean(submission.gradeComment),
},
digest: digestOf([
submission.isSubmitted,
submission.isGraded,
submission.grade ?? null,
submission.gradeComment ?? '',
submission.submittedText ?? '',
]),
});
}
// Rooms sit alongside courses rather than inside them. `course_id` carries
// the room id: the column is the container key, and widening its meaning
// keeps per-container carry-forward and the manifest working unchanged.
for (const room of snapshot.rooms) {
nodes.push({
kind: 'room',
nodeId: room.id,
courseId: room.id,
title: room.name,
body: '',
path: room.name,
meta: { boards: room.boards.length },
digest: digestOf([room.name]),
});
for (const board of room.boards) {
nodes.push({
kind: 'board',
nodeId: board.id,
courseId: room.id,
title: board.title,
body: board.text,
path: `${room.name}/${board.title}`,
meta: { columns: board.board.columns.length, fileCount: board.board.fileCount, inRoom: true },
digest: digestOf([board.title, board.text]),
});
}
}
// The user's own notes. `courseId` is set only when the note names one, so
// most notes sit outside any course — which is also what makes them survive
// a per-course crawl's carry-forward untouched.
for (const note of snapshot.notes ?? []) {
const common = {
courseId: note.courseId ?? null,
meta: {
notePath: note.path,
...(note.date ? { date: note.date } : {}),
...(note.source ? { source: note.source } : {}),
tags: note.tags,
modifiedAt: note.modifiedAt,
},
};
// A note written as one school day, with a heading per lesson, is indexed
// per lesson: one node for "Deutsch, 15.09." rather than one for "Monday".
// Indexed whole, every hit in it would read "my note, Monday" and lose the
// only thing that makes it findable — and "what did we do in Deutsch"
// would match a note whose other five lessons were something else.
const sections = noteSections(note);
if (sections.length > 0) {
for (const [index, section] of sections.entries()) {
nodes.push({
...common,
kind: 'note',
// The heading's position, not its text: renaming a heading should
// read as an edit, and two lessons of the same subject on one day
// must not collide.
nodeId: `${note.path}#${index}`,
title: section.subject ? `${section.subject}${note.date ?? note.title}` : section.heading,
body: [section.heading, section.text].filter(Boolean).join('\n'),
path: `Notizen/${note.path}${section.heading}`,
meta: { ...common.meta, heading: section.heading, ...(section.subject ? { subject: section.subject } : {}) },
digest: digestOf([section.heading, section.text]),
});
}
continue;
}
nodes.push({
...common,
kind: 'note',
nodeId: note.path,
title: note.title,
body: noteSearchText(note),
path: `Notizen/${note.path}`,
meta: { ...common.meta, ...(note.subject ? { subject: note.subject } : {}), bytes: note.bytes },
// The file's mtime is deliberately not in the digest: a sync tool that
// rewrites a file byte-for-byte must not show up as a changed note.
digest: digestOf([note.title, note.text, note.subject ?? '', note.date ?? '', note.tags]),
});
}
// The class register. The period id is the identity: it is stable, and it is
// what untis_lesson_topics takes, so a search hit can be followed up.
for (const entry of snapshot.lessonLog ?? []) {
const subject = entry.subject ?? 'Unterricht';
nodes.push({
kind: 'untis',
nodeId: `period-${entry.periodId}`,
courseId: null,
title: `${subject}${entry.date}`,
body: lessonLogText(entry),
path: `Klassenbuch/${subject}/${entry.date}`,
meta: {
periodId: entry.periodId,
lessonId: entry.lessonId,
date: entry.date,
start: entry.start,
end: entry.end,
...(entry.subject ? { subject: entry.subject } : {}),
...(entry.subjectLong ? { subjectLong: entry.subjectLong } : {}),
teachers: entry.teachers,
...(entry.exam ? { exam: entry.exam } : {}),
},
digest: digestOf([entry.topic ?? '', entry.notes, entry.exam ?? '', entry.homework]),
});
}
for (const file of snapshot.files) {
nodes.push(fileNode(file));
}
@@ -543,6 +752,8 @@ function fileNode(file: CrawledFile): StoredNode {
parentId: file.parentId,
securityCheckStatus: file.record.securityCheckStatus,
at: file.at,
source: file.source ?? 'files-storage',
...(file.fsPath ? { fsPath: file.fsPath } : {}),
},
// File records are less immutable than they look: `PATCH /file/rename/{id}`
// changes the name in place, keeping the id and the size, and teachers do
@@ -617,3 +828,7 @@ async function insertSearchDocs(
[crawlId],
);
}
function stripNul(value: string): string {
return value.includes('\u0000') ? value.replaceAll('\u0000', '') : value;
}

242
test/app-markdown.test.ts Normal file
View File

@@ -0,0 +1,242 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { markdownFromDom, markdownToHtml } from '../src/http/app/markdown.js';
import { parseHtml } from './mini-dom.ts';
/**
* The notes editor's Markdown bridge.
*
* The editor shows a note as formatted text and writes it back as Markdown, so
* every save runs the note through `markdownToHtml` and `markdownFromDom`. If
* that pair loses anything, it loses a lesson — these notes are the only record
* of what was actually said in the room, and there is no second copy to restore
* from. Hence the shape of almost every test here: put Markdown in, get the
* same Markdown back.
*/
/** Markdown → HTML → Markdown, the trip a note takes on every edit. */
function back(markdown: string): string {
return markdownFromDom(parseHtml(markdownToHtml(markdown)));
}
/**
* Asserts the trip is idempotent, and returns what it settles on.
*
* One pass may tidy — `*a*` becomes `_a_`, a ragged table lines up — and that
* is allowed, because the editor only rewrites a file the person has edited.
* A second pass changing anything is not: it would mean every save mangles the
* note a little further, which is how a term's notes rot into nothing.
*/
function settles(markdown: string): string {
const once = back(markdown);
assert.equal(back(once), once, 'the round trip is not stable');
return once;
}
/** Markdown that must survive the trip exactly as written. */
function unchanged(markdown: string): void {
assert.equal(settles(markdown), markdown);
}
test('headings keep their level', () => {
unchanged('## 1. Deutsch — 08:0008:45 · MEI · R 204');
unchanged('# Eins\n\n## Zwei\n\n### Drei\n\n#### Vier');
assert.match(markdownToHtml('## Deutsch'), /<h2>Deutsch<\/h2>/);
});
test('a paragraph keeps its line breaks without turning them into paragraphs', () => {
// How a note actually gets typed: shift-enter within a thought, enter
// between them.
unchanged('Erste Zeile\nzweite Zeile\n\nNeuer Absatz');
assert.equal(markdownToHtml('a\nb'), '<p>a<br>b</p>');
});
test('emphasis round-trips, and normalises to one spelling', () => {
unchanged('**fett** und _kursiv_ und ~~gestrichen~~');
assert.equal(settles('*kursiv*'), '_kursiv_');
assert.equal(settles('__fett__'), '**fett**');
assert.equal(markdownToHtml('**fett**'), '<p><strong>fett</strong></p>');
});
test('emphasis markers hug their text', () => {
// `** fett **` is four literal stars in every renderer there is.
const html = '<p>Merke:<strong> fett </strong>rest</p>';
assert.equal(markdownFromDom(parseHtml(html)), 'Merke: **fett** rest');
});
test('inline code keeps what is inside it literal', () => {
unchanged('Der Platzhalter `**nicht fett**` bleibt stehen.');
unchanged('`a | b`');
assert.equal(settles('``ein ` backtick``'), '``ein ` backtick``');
});
test('links keep their target, and a dangerous scheme is dropped', () => {
unchanged('[Arbeitsblatt](https://example.org/ab.pdf)');
// The label survives; only the target goes. A note is never worth less than
// its words, and nothing here should render a tappable `javascript:`.
assert.equal(back('[hier](javascript:alert)'), 'hier');
// A target with parentheses in it is a link, not a broken one.
unchanged('[Erörterung](https://de.wikipedia.org/wiki/Erörterung_(Textsorte))');
});
test('bullet lists nest', () => {
unchanged('- eins\n- zwei\n - zwei a\n - zwei b\n- drei');
});
test('numbered lists keep their numbering', () => {
unchanged('1. eins\n2. zwei\n3. drei');
// A list that starts elsewhere keeps its first number and renumbers the rest.
assert.equal(settles('3. drei\n4. vier'), '3. drei\n4. vier');
});
test('task lists keep their boxes', () => {
unchanged('- [ ] offen\n- [x] erledigt');
assert.match(markdownToHtml('- [x] fertig'), /<input type="checkbox" contenteditable="false" checked>/);
});
test('tables round-trip and line up', () => {
unchanged('| Präfix | Adressen |\n| --- | --- |\n| /24 | 254 |\n| /25 | 126 |');
// A ragged table is tidied once, then left alone.
assert.equal(settles('|a|b|\n|-|-|\n|1|2|'), '| a | b |\n| --- | --- |\n| 1 | 2 |');
});
test('a pipe inside a cell stays inside the cell', () => {
const md = '| Zeichen | Bedeutung |\n| --- | --- |\n| \\| | oder |';
assert.equal(settles(md), md);
});
test('blockquotes and rules survive', () => {
unchanged('> Merksatz des Lehrers\n> über zwei Zeilen');
unchanged('---');
});
test('fenced code keeps its language and its contents verbatim', () => {
unchanged('```bash\nip route add 10.0.0.0/8 via 10.1.1.1\n```');
// Indentation inside a fence is content, not structure.
unchanged('```\nif x:\n y = 1\n```');
});
test('underscores inside words are not emphasis', () => {
unchanged('snake_case_name bleibt ein Wort');
// `__` is bold in Markdown, though, and is normalised to the one spelling.
assert.equal(settles('__wirklich fett__'), '**wirklich fett**');
});
test('a note cannot smuggle HTML into the editor', () => {
const html = markdownToHtml('<script>alert(1)</script> & <b>nicht fett</b>');
assert.equal(html.includes('<script'), false);
assert.equal(html.includes('<b>'), false);
assert.match(html, /&lt;script&gt;/);
});
test('markup the editor does not model keeps its words', () => {
// What a paste from a web page leaves behind: the tags mean nothing here,
// the text means everything.
const html = '<p><span style="font-weight: 700">fett</span> <u>unterstrichen</u> <font color="red">rot</font></p>';
assert.equal(markdownFromDom(parseHtml(html)), '**fett** unterstrichen rot');
});
test('a browser\'s own line divs become paragraphs', () => {
// contenteditable produces these on every Enter, in every engine.
assert.equal(markdownFromDom(parseHtml('<div>eins</div><div>zwei</div>')), 'eins\n\nzwei');
assert.equal(markdownFromDom(parseHtml('<div><br></div>')), '');
});
test('text that looks like Markdown is escaped, and comes back as text', () => {
unchanged('2 \\* 3 \\* 4');
unchanged('\\- kein Listenpunkt');
unchanged('\\# keine Überschrift');
assert.equal(back('Gewicht \\_in kg\\_'), 'Gewicht \\_in kg\\_');
});
test('a whole day note survives unchanged', () => {
// The shape the app writes and the indexer reads back: one `##` per lesson,
// prose, a list, a subheading and a table underneath.
const note = [
'## 1. Deutsch — 08:0008:45 · MEI · R 204',
'',
'Dreischritt: These, Argument mit Beleg, Fazit.',
'',
'### Aufbau',
'',
'- Gegenargument nicht vergessen',
' - kam letztes Jahr in der Arbeit dran',
'- **Fazit** knapp halten',
'',
'## 2. LF07 — 08:5009:35 · Sb · R 108',
'',
'| Präfix | Nutzbare Adressen |',
'| --- | --- |',
'| /24 | 254 |',
'| /25 | 126 |',
'',
'> Kommt so in der Arbeit dran.',
].join('\n');
unchanged(note);
});
test('the lesson headings the indexer keys on come back verbatim', () => {
// `lessonHeading` writes these and `subjectFromHeading` reads the subject
// back out of them. An editor that rewrote the dash or the separator would
// file a day's notes under nothing.
for (const heading of [
'## 1. Deutsch — 08:0008:45 · MEI · R 204',
'## 3. LF07 — 10:3511:20 · Sb · R 108 (Vertretung)',
'## 5. Englisch — 12:1513:00',
]) {
unchanged(heading);
}
});
test('an empty note is empty, not a paragraph', () => {
assert.equal(markdownToHtml(''), '');
assert.equal(back(''), '');
assert.equal(back('\n\n \n'), '');
});
test('a list the browser nested as a sibling keeps its items', () => {
// What several engines produce when Tab indents a bullet: the nested list
// beside the items rather than inside one. Skipping it would drop
// everything under it without a trace.
const html = '<ul><li>eins</li><ul><li>eins a</li></ul><li>zwei</li></ul>';
assert.equal(markdownFromDom(parseHtml(html)), '- eins\n - eins a\n- zwei');
});
test('an item whose text the browser wrapped in a div is still one line', () => {
assert.equal(markdownFromDom(parseHtml('<ul><li><div>eins</div></li></ul>')), '- eins');
assert.equal(markdownFromDom(parseHtml('<ol><li><p>eins</p></li></ol>')), '1. eins');
});
test('what execCommand produces round-trips', () => {
// styleWithCSS is turned off, so bold and italic arrive as tags — but
// `<b>`/`<i>`, not `<strong>`/`<em>`.
assert.equal(markdownFromDom(parseHtml('<p><b>fett</b> und <i>kursiv</i></p>')), '**fett** und _kursiv_');
// A heading made by formatBlock, and the empty paragraph left behind.
assert.equal(markdownFromDom(parseHtml('<h2>Deutsch</h2><p><br></p>')), '## Deutsch');
});
test('a task list keeps its state through the DOM the editor builds', () => {
const html = '<ul><li class="task"><input type="checkbox" contenteditable="false">offen</li>' +
'<li class="task"><input type="checkbox" contenteditable="false" checked>fertig</li></ul>';
assert.equal(markdownFromDom(parseHtml(html)), '- [ ] offen\n- [x] fertig');
});
test('the editor\'s trailing escape line never reaches the file', () => {
// A table at the end of a contenteditable element is a dead end, so the
// editor keeps an empty paragraph after it. It must serialize to nothing,
// or every note with a table would grow a blank line on each save.
const html = markdownToHtml('| a | b |\n| --- | --- |\n| 1 | 2 |') + '<p><br></p>';
assert.equal(markdownFromDom(parseHtml(html)), '| a | b |\n| --- | --- |\n| 1 | 2 |');
});
test('an empty cell stays an empty cell', () => {
// The editor puts a <br> in blank cells so the caret can reach them.
unchanged('| a | b |\n| --- | --- |\n| | 2 |');
assert.match(markdownToHtml('| a |\n| --- |\n| |'), /<td><br><\/td>/);
});
test('a row the editor appended round-trips', () => {
const html = '<table><thead><tr><th>a</th><th>b</th></tr></thead>' +
'<tbody><tr><td>1</td><td>2</td></tr><tr><td><br></td><td><br></td></tr></tbody></table>';
assert.equal(markdownFromDom(parseHtml(html)), '| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |');
});

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

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

View File

@@ -1,9 +1,12 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { bearerAuth } from '../src/http/auth.ts';
import { bearerAuth, pathSecret } from '../src/http/auth.ts';
function run(headers: Record<string, string>): { status?: number; passed: boolean } {
const middleware = bearerAuth('correct-horse-battery-staple');
function run(
headers: Record<string, string>,
accepted: string | string[] = 'correct-horse-battery-staple',
): { status?: number; passed: boolean } {
const middleware = bearerAuth(accepted);
let status: number | undefined;
let passed = false;
const req = { get: (name: string) => headers[name.toLowerCase()] } as never;
@@ -32,6 +35,24 @@ describe('bearerAuth', () => {
assert.equal(run({ 'x-api-key': 'correct-horse-battery-staple' }).passed, true);
});
it('accepts the token bare in Authorization, as claude.ai sends a header typed without "Bearer "', () => {
assert.equal(run({ authorization: 'correct-horse-battery-staple' }).passed, true);
});
it('accepts it via x-auth-token, the other header connector dialogs offer', () => {
assert.equal(run({ 'x-auth-token': 'correct-horse-battery-staple' }).passed, true);
});
it('accepts any of several tokens, and nothing else', () => {
const accepted = ['correct-horse-battery-staple', 'connector-token-0123456789abcdef'];
assert.equal(run({ authorization: 'Bearer correct-horse-battery-staple' }, accepted).passed, true);
assert.equal(run({ authorization: 'Bearer connector-token-0123456789abcdef' }, accepted).passed, true);
assert.equal(run({ 'x-api-key': 'connector-token-0123456789abcdef' }, accepted).passed, true);
const refused = run({ authorization: 'Bearer connector-token-0123456789abcde' }, accepted);
assert.equal(refused.passed, false);
assert.equal(refused.status, 401);
});
it('is case-insensitive about the scheme but not the token', () => {
assert.equal(run({ authorization: 'bearer correct-horse-battery-staple' }).passed, true);
assert.equal(run({ authorization: 'Bearer CORRECT-HORSE-BATTERY-STAPLE' }).passed, false);
@@ -53,3 +74,39 @@ describe('bearerAuth', () => {
}
});
});
describe('pathSecret', () => {
const secret = 'a'.repeat(40) + 'B-_9';
function visit(presented: unknown): { status?: number; passed: boolean } {
const middleware = pathSecret(secret);
let status: number | undefined;
let passed = false;
const req = { params: { secret: presented } } as never;
const res = {
status(code: number) {
status = code;
return this;
},
json() {
return this;
},
} as never;
middleware(req, res, () => {
passed = true;
});
return { status, passed };
}
it('lets the exact secret through', () => {
assert.equal(visit(secret).passed, true);
});
it('answers anything else like an unknown path, not like a refused login', () => {
for (const presented of [undefined, '', 'mcp', secret.slice(0, -1), `${secret}x`, secret.toUpperCase()]) {
const result = visit(presented);
assert.equal(result.passed, false, `should reject ${JSON.stringify(presented)}`);
assert.equal(result.status, 404);
}
});
});

View File

@@ -109,3 +109,61 @@ describe('getCards chunking', () => {
assert.equal(cards.length, 2, 'one card from each of the two chunks');
});
});
describe('download timeouts', () => {
/**
* A real local server, because the timing is the point: a stubbed fetch
* cannot model bytes that keep arriving slowly. requestTimeoutMs is 1000ms.
*/
async function withServer(
handler: (res: import('node:http').ServerResponse) => void,
run: (base: string) => Promise<void>,
) {
const { createServer } = await import('node:http');
const server = createServer((_req, res) => handler(res));
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as import('node:net').AddressInfo;
try {
await run(`http://127.0.0.1:${port}`);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
const slowButSteady = (res: import('node:http').ServerResponse) => {
res.writeHead(200, { 'content-type': 'application/pdf' });
let sent = 0;
const tick = setInterval(() => {
res.write(Buffer.alloc(10, 65));
if (++sent === 4) {
clearInterval(tick);
res.end();
}
}, 400);
};
it('lets a download run past the request timeout while bytes keep arriving', async () => {
await withServer(slowButSteady, async (base) => {
// 4 chunks, 400ms apart: 1.6s in total against a 1s timeout, never 1s of silence.
const client = new SchulcloudClient({ ...config, baseUrl: base } as never);
const file = await client.getBytes('/file', 'slow.pdf');
assert.equal(file.bytes.length, 40);
assert.equal(file.truncated, false);
});
});
it('abandons a download that stalls', async () => {
await withServer(
(res) => {
res.writeHead(200, { 'content-type': 'application/pdf' });
res.write(Buffer.alloc(10, 65));
// …and then nothing, well past the 1s idle limit.
},
async (base) => {
const client = new SchulcloudClient({ ...config, baseUrl: base } as never);
await assert.rejects(client.getBytes('/file', 'stalled.pdf'), /no data received for 1s/);
},
);
});
});

Some files were not shown because too many files have changed in this diff Show More