diff --git a/.env.example b/.env.example
index b585457..bacd13f 100644
--- a/.env.example
+++ b/.env.example
@@ -42,6 +42,12 @@ 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
+
# 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.
diff --git a/CLAUDE.md b/CLAUDE.md
index 3301948..5bd17a5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -70,7 +70,12 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
- **`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.
@@ -146,8 +151,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
@@ -174,7 +196,27 @@ 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.** There is no quiz module or endpoint
+ upstream: interactive exercises are H5P elements, whose `contentId` is the
+ only handle onto the content, or external (LTI) tools behind
+ `contextExternalToolId`. Say that rather than looking for a quiz API.
+- **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. This one is genuinely unavailable, not merely uncovered.
- **`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.
diff --git a/README.md b/README.md
index 147fb3f..f7c480d 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ instance, not inferred from the upstream source.
> *"Find the material about Verschlüsselung and explain the Caesar cipher worksheet."*
> *"Summarise the routing lesson from the LF10 course."*
-Thirteen tools, all read-only:
+Twenty tools, all read-only:
| | |
|---|---|
@@ -36,6 +36,9 @@ Thirteen tools, all read-only:
| `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 |
| `api_get` | GET-only escape hatch for uncovered API surface |
diff --git a/local-instance/README.md b/local-instance/README.md
index d67aa45..4ec37b2 100644
--- a/local-instance/README.md
+++ b/local-instance/README.md
@@ -252,6 +252,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.
diff --git a/local-instance/docker-compose.yml b/local-instance/docker-compose.yml
index 0fb9c6e..df87664 100644
--- a/local-instance/docker-compose.yml
+++ b/local-instance/docker-compose.yml
@@ -160,6 +160,10 @@ services:
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}
diff --git a/local-instance/file-preview/policy.xml b/local-instance/file-preview/policy.xml
new file mode 100644
index 0000000..d2d4cee
--- /dev/null
+++ b/local-instance/file-preview/policy.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs
index 3298473..c45fb36 100644
--- a/scripts/smoke.mjs
+++ b/scripts/smoke.mjs
@@ -126,6 +126,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 +210,19 @@ 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== rooms ==');
// 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
@@ -236,6 +261,15 @@ 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]);
}
diff --git a/src/config.ts b/src/config.ts
index 7413c83..06dd07f 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -35,6 +35,8 @@ 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;
/** How often to re-crawl on a timer. Zero = only on demand. */
crawlIntervalMs: number;
}
@@ -45,6 +47,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;
@@ -82,6 +91,10 @@ export function loadConfig(): Config {
// 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),
crawlIntervalMs: intAllowingZero('CRAWL_INTERVAL_MS', 6 * 60 * 60_000),
};
}
diff --git a/src/context.ts b/src/context.ts
index 26706f2..094803f 100644
--- a/src/context.ts
+++ b/src/context.ts
@@ -1,6 +1,6 @@
import type { Config } from './config.ts';
import { SchulcloudClient } from './core/client.ts';
-import type { MeResponse } from './core/types.ts';
+import type { LegacyUser, MeResponse } from './core/types.ts';
import type { Indexer } from './indexer/indexer.ts';
import type { Store } from './store/store.ts';
@@ -18,6 +18,17 @@ export class ServerContext {
readonly store: Store | undefined;
readonly indexer: Indexer | undefined;
private identity: Promise | 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>();
constructor(config: Config, shared?: { client?: SchulcloudClient; store?: Store; indexer?: Indexer }) {
this.config = config;
@@ -40,8 +51,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 {
+ 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 {
+ 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();
}
}
diff --git a/src/core/board.ts b/src/core/board.ts
index 7e418af..ea8e65b 100644
--- a/src/core/board.ts
+++ b/src/core/board.ts
@@ -26,6 +26,16 @@ 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;
+ /** Which configured tool an externalTool element launches. */
+ contextExternalToolId?: string;
+ /** What a deleted element used to be. */
+ deletedElementType?: string;
raw: Record;
}
@@ -120,14 +130,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;
}
diff --git a/src/core/client.ts b/src/core/client.ts
index 5652217..b6dc7cc 100644
--- a/src/core/client.ts
+++ b/src/core/client.ts
@@ -3,19 +3,27 @@ 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,
@@ -27,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;
@@ -365,6 +376,27 @@ export class SchulcloudClient {
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 {
+ const body = await this.getJson<{ data?: RoomApplicant[] }>(
+ `/api/v3/rooms/${encodeURIComponent(roomId)}/applicants`,
+ );
+ return body.data ?? [];
+ }
+
+ async listRoomInvitationLinks(roomId: string): Promise {
+ const body = await this.getJson<{ data?: RoomInvitationLink[] }>(
+ `/api/v3/rooms/${encodeURIComponent(roomId)}/room-invitation-links`,
+ );
+ return body.data ?? [];
+ }
+
// --- column boards ---------------------------------------------------
getBoardSkeleton(boardId: string): Promise {
@@ -441,6 +473,83 @@ 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 {
+ return this.getJson(
+ `/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,
+ width?: PreviewWidth,
+ ): Promise {
+ // 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 {
+ return this.getJson(`/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 {
+ return this.getJson(`/api/v1/users/${encodeURIComponent(userId)}`);
+ }
+
+ // --- groups and classes ------------------------------------------------
+
+ /** Classes ("Klassen") this account belongs to, with teacher names. */
+ async listClasses(): Promise {
+ const body = await this.getJson>('/api/v3/groups/class', { limit: MAX_PAGE_SIZE });
+ return body.data ?? [];
+ }
+
+ /** Groups this account belongs to — room membership groups, classes, courses. */
+ async listGroups(): Promise {
+ const body = await this.getJson>('/api/v3/groups', { limit: MAX_PAGE_SIZE });
+ return body.data ?? [];
+ }
}
/**
diff --git a/src/core/crawl.ts b/src/core/crawl.ts
index 1970e20..94436aa 100644
--- a/src/core/crawl.ts
+++ b/src/core/crawl.ts
@@ -1,8 +1,10 @@
import type { Config } from '../config.ts';
import { assembleBoard, type AssembledBoard } from './board.ts';
import type { SchulcloudClient } from './client.ts';
+import { fetchHomeworkPage } from './homework-page.ts';
+import { fetchLessonTaskLinks, withScrapedIds } from './lesson-page.ts';
import { htmlToText, normalizeObjectId } from './text.ts';
-import type { CourseMetadata, FileRecord, TaskContent } from './types.ts';
+import type { CourseMetadata, FileParentType, FileRecord, TaskContent } from './types.ts';
/**
* Walks an account's entire content tree and returns it as one snapshot.
@@ -37,12 +39,34 @@ export interface Breadcrumb {
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;
}
+/**
+ * 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 {
id: string;
title: string;
@@ -97,6 +121,8 @@ export interface Snapshot {
/** Rooms ("Räume"), a separate space from courses. */
rooms: CrawledRoom[];
files: CrawledFile[];
+ /** Populated only when `includePersonalFiles` is set; see that option. */
+ submissions: CrawledSubmission[];
/**
* 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
@@ -107,12 +133,23 @@ 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;
/**
* Read the text of collaborative text editor (Etherpad) pads, which needs a
* second credentialled hop outside the API. Omit to leave pads unread.
@@ -133,14 +170,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) });
@@ -159,8 +212,17 @@ export async function crawl(client: SchulcloudClient, options: CrawlOptions): Pr
crawled.sort((a, b) => a.course.id.localeCompare(b.course.id));
rooms.sort((a, b) => a.id.localeCompare(b.id));
files.sort((a, b) => a.record.id.localeCompare(b.record.id));
+ submissions.sort((a, b) => a.id.localeCompare(b.id));
- return { crawledAt: new Date(), schoolId: options.schoolId, courses: crawled, rooms, files, failures };
+ return {
+ crawledAt: new Date(),
+ schoolId: options.schoolId,
+ courses: crawled,
+ rooms,
+ files,
+ submissions,
+ failures,
+ };
}
/**
@@ -281,10 +343,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[] = [];
@@ -307,6 +375,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 = {
@@ -333,6 +404,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({
@@ -346,16 +456,94 @@ async function crawlCourse(
}
}
+ // 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' },
+ });
+ }
+ }
+
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 {
+ 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',
+ },
+ });
+ }
+ }
+ }
}
async function listFiles(
client: SchulcloudClient,
schoolId: string,
- parentType: 'lessons' | 'tasks',
+ parentType: FileParentType,
parentId: string,
): Promise {
const page = await client
diff --git a/src/core/etherpad.ts b/src/core/etherpad.ts
index 4b8a49b..643002c 100644
--- a/src/core/etherpad.ts
+++ b/src/core/etherpad.ts
@@ -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 {
+ 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;
+ }
+}
diff --git a/src/core/homework-page.ts b/src/core/homework-page.ts
index b3f7845..2ddb31a 100644
--- a/src/core/homework-page.ts
+++ b/src/core/homework-page.ts
@@ -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 {
+ 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 {
+ const html = await fetchHomeworkHtml(config, taskId);
+ return html === undefined ? undefined : parseHomeworkPage(html);
+}
+
+async function fetchHomeworkHtml(config: Config, taskId: string): Promise {
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(/]*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(
+ ``,
+ ).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. */
diff --git a/src/core/text.ts b/src/core/text.ts
index 03c7125..c98be2c 100644
--- a/src/core/text.ts
+++ b/src/core/text.ts
@@ -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 ` ` 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(/ /gi, '\n')
- .replace(/<\/(p|div|h[1-6]|li|tr)>/gi, '\n')
+ // A cell whose text is wrapped in its own
— which is what the
+ // editor produces — would otherwise break its row in half.
+ .replace(/<(td|th)([^>]*)>\s*
]*>/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(/