Commit Graph

11 Commits

Author SHA1 Message Date
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
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
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
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
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
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
ab10bbcd14 Stop calling a feedback-only grade "no numeric grade recorded"
You were right that the output was wrong, though not quite for the
reason given: the schema has no textual grade. It is
grade: { type: Number, min: 0, max: 100 } with gradeComment: String, so
"OK" is the comment, not the grade.

What the model does allow is exactly your case — teachers grade with the
comment alone and leave grade unset. Rendering that as "graded (no
numeric grade recorded)" reads as missing or broken data when in fact
the written verdict is the whole grade. It now says "graded by feedback,
with no percentage given", and reserves the it-is-absent wording for
when there is genuinely neither a percentage nor a comment.

Also fixes a real misrepresentation next to it: grade is a percentage,
and both the detail and list views printed it bare, so an 85 could be
read as a mark out of 100, 15 or 6. Now rendered as 85%.

formatGradeState is pure and covered by seven cases, including 0% staying
distinct from "no grade" — the bug that an `if (grade)` test would have
introduced.

85 tests, 38/38 smoke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 13:32:08 +02:00
a8badc2fd1 Read submitted text and teacher feedback from the homework page
You were right that this data never reaches the browser as an API call.
The legacy front end calls the Feathers API server-side for
submission.comment, submission.grade and submission.gradeComment and
renders them into GET /homework/{taskId}. That Feathers API is not
exposed publicly — /api/v1/* 404s — so the rendered page is the only way
to reach these fields from outside.

core/homework-page.ts parses it, hooked on the data-testid attributes
the project's own e2e tests use rather than incidental markup. The page
authenticates by jwt *cookie*; an Authorization header is ignored and
redirects to the identity provider. Every field is optional and parse
failures return undefined, so a markup change degrades to "not found"
and cannot break get_task. The wording distinguishes the two: absent
feedback is reported as not found, never as none given.

Measured on one course: 4 of 7 graded submissions carry feedback no API
call can return — "vollständig und nachvollziehbar", "Feedback siehe
Zettel", and so on.

This exposed a bug in a shared utility: htmlToText decoded only six
entities, so any named entity passed through raw. German content makes
that routine — "vollst&auml;ndig" would have reached the model verbatim
from boards and task descriptions too, not just here. It now decodes
named, decimal and hex references in one pass, so &amp;auml; stays
literal instead of decoding twice, and leaves unknown names alone rather
than mangling them.

Also fixes a documented-recovery bug found while restoring the session:
`docker compose restart` does not re-read env_file, so it silently kept
serving the dead token. `up -d` is correct and the docs said the wrong
thing.

78 tests, 38/38 smoke; verified end to end through Claude Code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 13:25:35 +02:00
ac08e17b48 Expose submissions: list_submissions, and get_task shows your own
Adds what the API actually permits, which is less than the request asked
for and worth being precise about.

GET /api/v3/submissions/status/task/{taskId} is the only submission
route — no list, no fetch-by-id — so a task id is the only way in. The
probe in the report missed it by trying /api/v3/submissions (404). Its
payload is {id, submitters, isSubmitted, isGraded, grade,
submittingCourseGroupName} and nothing more: no submitted text, no grade
comment, no graded-at. Those lived on /api/v1, which this instance does
not serve at all (404 across the board, confirmed — not the proxy). So
"what feedback did I get" is answerable only when the feedback is a file.

Submitted files are reachable, which covers the main workflow:
get_task now shows the submission id, graded state, grade, group, and
the handed-in files with ids ready for download_file. list_submissions
surveys tasks for "what have I handed in" and "what is still ungraded".
Both state the text/feedback gap rather than implying none was given.

Two things found while building it:

files-storage ignores the parentType path segment when listing —
.../gradings/{id} returns the same records, saying parentType
"submissions". Filtering on each record's own parentType, or a student's
own upload gets reported back as teacher feedback.

get_task could not find this task at all: the task lists only cover the
dashboard, and group-project tasks are absent from both, so it claimed
the id was wrong for a task the account can plainly see. It now falls
back to scanning course pages.

Also bounds live search by measured cost: resolving attachments needs a
request per board element, which is 2s for one course but 325s for all
of them — beyond any client timeout. An unscoped fresh search now reads
text only and says so.

38/38 smoke checks; verified end to end through Claude Code against a
real graded group submission.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 23:19:11 +02:00
c79f1b120d Index-backed search, /api surfaces, image-only PDF detection
search now queries the Postgres index and states its freshness in every
result, with fresh=true bypassing it for a live crawl — the agent can
always get current data rather than being quietly misled by a stale
index. Adds refresh_index (per-course by default; a full crawl is ~270
requests), what_changed (generation diff — the API has no changed-since
filter of any kind), and index_status.

/api gives the CLI its backend behind the same bearer token as /mcp:
GET /manifest (cursor + per-file status), GET /files/:id (served from
the mirror with Range support, falling back to a live proxy for files
too large to mirror), GET /status, POST /refresh. Bytes go over plain
HTTP rather than MCP because base64 in JSON-RPC costs a third more and
buffers whole files. An unresolvable manifest cursor returns 409 rather
than silently meaning "everything is new", so a client cannot be tricked
into a full re-download.

Verified end to end against the live instance and a real Postgres:
crawl -> index -> German FTS -> manifest -> ranged download, with 401
on missing token, 400 on a malformed id, and 429 on a too-soon refresh.

Two findings worth recording. The build silently omitted the .sql
migrations from dist, which the store's graceful degradation turned into
"running without the index" rather than a crash — now copied by a build
step. And 3 of 4 sampled course PDFs have no embedded fonts at all: they
are scans, so extraction legitimately yields nothing. That is now
detected and reported as image-only with OCR named as the missing piece,
instead of an indistinguishable "0 characters". It revises the roadmap's
"OCR not needed" note, which held for reading images but not for
indexing them.

49 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:16:38 +02:00
81dd633863 Extract core/, lift the crawler out of the search tool
Moves the reusable half into src/core/ (client, types, board, extract,
text, keepalive) and the MCP half into src/mcp/. The layering was
already clean — nothing in core imported app code or read process.env —
so this is a move, not a redesign, and the smoke suite stayed the oracle
throughout.

The substantive part is core/crawl.ts. The course->board->card->element
->file traversal previously existed only inside tools/search.ts, and the
indexer, what's-new diff and file mirror all need it. It now returns a
typed Snapshot with breadcrumbs, sorted so two crawls of unchanged
content compare equal. Metadata only: downloading and extracting bytes
is an order of magnitude more expensive and only the indexer wants it.

core/match.ts holds the keyword matching, which makes it testable
without a network, and core/text.ts gains the fold/tokenize/snippet
helpers (accent folding is not optional for German).

search now finds strictly more than before — 5 hits vs 3 for
"Datenschutz" — because the snapshot surfaces file-name matches the old
streaming walk skipped. 34 unit tests and 30/30 smoke checks pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:04:52 +02:00