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>
This commit is contained in:
MechaCat02
2026-09-13 19:24:53 +02:00
parent 3a168e37e5
commit 1de026ca43
19 changed files with 584 additions and 95 deletions

View File

@@ -14,6 +14,10 @@ import type {
Paginated,
SubmissionStatus,
LessonLinkedTask,
RoomBoardItem,
RoomDetails,
RoomItem,
RoomMember,
TaskContent,
} from './types.ts';
@@ -323,6 +327,44 @@ 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 ?? [];
}
// --- column boards ---------------------------------------------------
getBoardSkeleton(boardId: string): Promise<BoardSkeleton> {

View File

@@ -17,7 +17,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;
@@ -70,10 +77,25 @@ 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[];
/**
* Anything that could not be read, with the reason. Boards appear here too:
@@ -127,12 +149,130 @@ 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);
// 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));
rooms.sort((a, b) => a.id.localeCompare(b.id));
files.sort((a, b) => a.record.id.localeCompare(b.record.id));
return { crawledAt: new Date(), schoolId: options.schoolId, courses: crawled, files, failures };
return { crawledAt: new Date(), schoolId: options.schoolId, courses: crawled, rooms, files, 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,
});
} 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);
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(
@@ -206,60 +346,7 @@ 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,
});
} 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 };

View File

@@ -1,4 +1,4 @@
import type { Snapshot } from './crawl.ts';
import type { CrawledBoard, Snapshot } from './crawl.ts';
import { matchesAll, snippet, tokenize } from './text.ts';
/**
@@ -17,10 +17,51 @@ 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';
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);
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 +78,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 +101,16 @@ 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);
}
for (const file of snapshot.files) {
if (matchesAll(file.record.name, terms)) {
hits.push({

View File

@@ -239,6 +239,61 @@ 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 — 'room_edit_content' and friends. */
allowedOperations?: string[];
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;
}
export const FILE_PARENT_TYPES: FileParentType[] = [
'users',
'schools',