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>
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>
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>
The CLI talks only to the Pi's /api surface and holds no Schulcloud
credential — only the same bearer token the Claude connector uses. That
is not layering for its own sake: a Schulcloud session dies after two
hours idle and a CLI process lives for seconds, so a CLI with its own
token would be dead most times you reached for it. Routing through the
Pi means one session, one keepalive, one monthly cookie paste.
sync is a one-way mirror, which follows from the data rather than from
scope-cutting: file records are immutable upstream, so there is no
versioning, no conflict resolution and no merge. State is keyed by file
record id with the path as derived output, so an upstream rename moves
the local file instead of duplicating it — verified against the live
server. Verification is size-only because the download endpoint exposes
no ETag and Schulcloud publishes no hash; size still catches the failure
that happens, a truncated download. Downloads land on a .part neighbour
and are renamed, so an interrupted run leaves no half-file that a later
run mistakes for complete. Deletions are reported but not propagated —
a teacher removing a worksheet is no reason to destroy the student's
copy — with --prune to opt in.
what_changed now clamps to the oldest stored generation instead of
refusing, and says it did: "what's new this week" is a reasonable
question to ask a two-day-old index.
Two build bugs caught by the checks rather than by luck: the smoke
harness constructed the app without services, so the index-backed tools
were never exercised; and the Docker build could not see
scripts/copy-assets.mjs, so the image would have shipped without
migrations and silently degraded to live-only.
67 unit tests (9 needing Postgres), smoke green both ways — 34 checks
with an index, 32 without, because graceful degradation is a supported
mode and not a fallback nobody runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>