diff --git a/CLAUDE.md b/CLAUDE.md index 863a604..3301948 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,7 +111,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 diff --git a/docs/API.md b/docs/API.md index e9b3fc2..0778e7f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 diff --git a/docs/CLI.md b/docs/CLI.md index 36d3b5f..b598b2f 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -43,6 +43,9 @@ schulcloud refresh [--course ] [--force] `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`. diff --git a/local-instance/README.md b/local-instance/README.md index 96a3bdb..d67aa45 100644 --- a/local-instance/README.md +++ b/local-instance/README.md @@ -193,6 +193,12 @@ eval "$(./scripts/mcp-env.sh)" # as the demo student cd .. && npm run smoke # 39 checks against the local instance ``` +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 diff --git a/local-instance/scripts/simulate-teacher.mjs b/local-instance/scripts/simulate-teacher.mjs index 116d0eb..aac2b7f 100755 --- a/local-instance/scripts/simulate-teacher.mjs +++ b/local-instance/scripts/simulate-teacher.mjs @@ -124,6 +124,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: '

Raum-Inhalt, nicht Kurs-Inhalt. Suchbegriff: Projektsteuerung.

', + 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' @@ -314,6 +360,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,7 +371,9 @@ 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}`)], ]; for (const [what, fn] of tries) { diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index ba1bc7b..3298473 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -198,6 +198,29 @@ if (fileId) { check('download_file', false, 'no file id found to test with'); } +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 +// 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); + 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== search =='); const searchTerm = process.env.SMOKE_SEARCH ?? 'Datenschutz'; const search = await call('search', { query: searchTerm, fresh: true, courseId: courseIds[0] }); diff --git a/src/bin/cli.ts b/src/bin/cli.ts index b932f1a..f6e70eb 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -26,6 +26,9 @@ const USAGE = `schulcloud — browse and mirror your Schulcloud files schulcloud sync [--dry-run] [--full] [--prune] [--dir ] [--jobs ] schulcloud refresh [--course ] [--force] +--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()} `; @@ -107,6 +110,8 @@ async function list(flags: Flags): Promise { 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) { diff --git a/src/core/client.ts b/src/core/client.ts index ccddec4..5652217 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -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 { + const body = await this.getJson<{ data?: RoomItem[] }>('/api/v3/rooms'); + return body.data ?? []; + } + + getRoom(roomId: string): Promise { + return this.getJson(`/api/v3/rooms/${encodeURIComponent(roomId)}`); + } + + async listRoomBoards(roomId: string): Promise { + const body = await this.getJson>( + `/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 { + const body = await this.getJson<{ data?: RoomMember[] }>( + `/api/v3/rooms/${encodeURIComponent(roomId)}/members`, + ); + return body.data ?? []; + } + // --- column boards --------------------------------------------------- getBoardSkeleton(boardId: string): Promise { diff --git a/src/core/crawl.ts b/src/core/crawl.ts index 43d20f2..1970e20 100644 --- a/src/core/crawl.ts +++ b/src/core/crawl.ts @@ -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 { + 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 { + 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 }; diff --git a/src/core/match.ts b/src/core/match.ts index 2f8eaac..31118b6 100644 --- a/src/core/match.ts +++ b/src/core/match.ts @@ -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({ diff --git a/src/core/types.ts b/src/core/types.ts index 0506bd2..ea534e1 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -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 { + 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', diff --git a/src/indexer/indexer.ts b/src/indexer/indexer.ts index 21b1aab..1433796 100644 --- a/src/indexer/indexer.ts +++ b/src/indexer/indexer.ts @@ -23,6 +23,8 @@ export interface IndexResult { crawlId: number; scope: string; courses: number; + /** Rooms walked. Zero is normal — many accounts are in none. */ + rooms: number; files: number; mirrored: number; extracted: number; @@ -123,6 +125,7 @@ export class Indexer { crawlId, scope, courses: snapshot.courses.length, + rooms: snapshot.rooms.length, files: snapshot.files.length, mirrored, extracted, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index b547daa..8dcc81e 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -8,6 +8,7 @@ import { registerOverviewTools } from './tools/overview.ts'; import { registerRawTool } from './tools/raw.ts'; import { registerIndexTools } from './tools/index-tools.ts'; import { registerSearchTool } from './tools/search.ts'; +import { registerRoomTools } from './tools/rooms.ts'; import { registerSubmissionTools } from './tools/submissions.ts'; export const SERVER_NAME = 'schulcloud-mcp'; @@ -48,6 +49,7 @@ export function createServer(config: Config, services?: Services): { server: Mcp registerOverviewTools(server, context); registerContentTools(server, context); + registerRoomTools(server, context); registerFileTools(server, context); registerSearchTool(server, context); registerSubmissionTools(server, context); diff --git a/src/mcp/tools/index-tools.ts b/src/mcp/tools/index-tools.ts index adef518..d59908e 100644 --- a/src/mcp/tools/index-tools.ts +++ b/src/mcp/tools/index-tools.ts @@ -40,7 +40,7 @@ export function registerIndexTools(server: McpServer, context: ServerContext): v [ `- 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}`, `- Newly mirrored: ${result.mirrored}, text extracted: ${result.extracted}, skipped: ${result.skipped}`, `- Took ${(result.durationMs / 1000).toFixed(1)}s`, result.failures.length > 0 @@ -73,7 +73,7 @@ export function registerIndexTools(server: McpServer, context: ServerContext): v .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'])) .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.'), diff --git a/src/mcp/tools/rooms.ts b/src/mcp/tools/rooms.ts new file mode 100644 index 0000000..167012b --- /dev/null +++ b/src/mcp/tools/rooms.ts @@ -0,0 +1,114 @@ +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 { RoomBoardItem, 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 { + // 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, boards, members] = await Promise.all([ + context.client.getRoom(roomId), + context.client.listRoomBoards(roomId).catch(() => [] as RoomBoardItem[]), + context.client.listRoomMembers(roomId).catch(() => [] as RoomMember[]), + ]); + return text(formatRoom(room.name, roomId, boards, members)); + } catch (error) { + return toToolError(error, `read room ${roomId}`); + } + }, + ); +} + +function formatRoom(name: string, roomId: string, boards: RoomBoardItem[], members: RoomMember[]): string { + // 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/, '')}` : ''}`; + }); + + return joinSections([ + heading(2, name), + `Room id: \`${roomId}\``, + 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, + ]); +} diff --git a/src/mcp/tools/search.ts b/src/mcp/tools/search.ts index 3962b14..a4cb752 100644 --- a/src/mcp/tools/search.ts +++ b/src/mcp/tools/search.ts @@ -32,7 +32,7 @@ export function registerSearchTool(server: McpServer, context: ServerContext): v 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'])) .optional() .describe('Restrict to certain kinds of thing, e.g. ["file"] to find documents only.'), limit: z.number().int().min(1).max(100).default(30).describe('Maximum number of hits to return.'), diff --git a/src/store/migrations/001_init.sql b/src/store/migrations/001_init.sql index edd6461..7f447df 100644 --- a/src/store/migrations/001_init.sql +++ b/src/store/migrations/001_init.sql @@ -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 '', diff --git a/src/store/store.ts b/src/store/store.ts index 3f67a81..5b68aef 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -14,7 +14,7 @@ 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'; +export type NodeKind = 'course' | 'room' | 'board' | 'lesson' | 'task' | 'file'; export interface StoredNode { kind: NodeKind; @@ -521,6 +521,35 @@ export function snapshotToNodes(snapshot: Snapshot): StoredNode[] { } } + // 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]), + }); + } + } + for (const file of snapshot.files) { nodes.push(fileNode(file)); } diff --git a/test/store.test.ts b/test/store.test.ts index b1e27ca..83c3924 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -26,11 +26,22 @@ function assertDisposable(url: string): void { } } -function snapshot(courses: { id: string; title: string; boardText?: string; files?: { id: string; name: string; size: number }[] }[]): Snapshot { +function snapshot( + courses: { id: string; title: string; boardText?: string; files?: { id: string; name: string; size: number }[] }[], + rooms: { id: string; name: string; boardText?: string }[] = [], +): Snapshot { return { crawledAt: new Date(), schoolId: 'school1', failures: [], + rooms: rooms.map((r) => ({ + id: r.id, + name: r.name, + boards: r.boardText + ? [{ id: `${r.id}-b`, title: 'Raum-Board', courseId: r.id, text: r.boardText, + board: { id: `${r.id}-b`, title: 'Raum-Board', columns: [], fileCount: 0 } }] + : [], + })), courses: courses.map((c) => ({ course: { id: c.id, title: c.title, shortTitle: c.title.slice(0, 2), displayColor: '#000' }, title: c.title, @@ -122,6 +133,25 @@ describe('Store', { skip: DB_URL ? false : 'set TEST_DATABASE_URL to run' }, () assert.ok(!diff.removed.some((n) => n.nodeId === 'f2'), 'and not a deleted one'); }); + it('stores rooms alongside courses, and notices when one changes', async () => { + // Rooms are a separate space, not a kind of course: they must land in the + // index under their own kind, or room content becomes unsearchable the way + // submitted files once were. + const before = await store.saveSnapshot( + snapshot([{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }], [{ id: 'r1', name: 'Projektraum', boardText: 'Projektsteuerung' }]), + 'full', + ); + const hits = await store.search('Projektsteuerung', { limit: 5 }); + assert.ok(hits.some((h) => h.nodeId === 'r1-b'), 'a room board is searchable'); + + const after = await store.saveSnapshot( + snapshot([{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung' }], [{ id: 'r1', name: 'Projektraum Informatik', boardText: 'Projektsteuerung' }]), + 'full', + ); + const diff = await store.diff(before, after); + assert.ok(diff.changed.some((n) => n.nodeId === 'r1' && n.kind === 'room'), 'a renamed room is reported as changed'); + }); + it('carries other courses forward on a per-course crawl', async () => { await store.saveSnapshot( snapshot([