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>
129 lines
4.0 KiB
TypeScript
129 lines
4.0 KiB
TypeScript
import type { CrawledBoard, Snapshot } from './crawl.ts';
|
|
import { matchesAll, snippet, tokenize } from './text.ts';
|
|
|
|
/**
|
|
* Keyword matching over a crawled snapshot.
|
|
*
|
|
* This is the fallback path: when the Postgres index is unavailable or a
|
|
* caller asks for guaranteed-fresh results, we crawl and match in memory
|
|
* instead. Pure and synchronous, so it is testable without a network or a
|
|
* database.
|
|
*/
|
|
|
|
export interface Hit {
|
|
courseId: string;
|
|
courseTitle: string;
|
|
/** Where the match was found, in human terms. */
|
|
where: string;
|
|
/** Id to pass to a follow-up tool, with the tool that takes it. */
|
|
targetId: string;
|
|
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 [];
|
|
|
|
const hits: Hit[] = [];
|
|
const push = (hit: Hit) => {
|
|
hits.push(hit);
|
|
};
|
|
|
|
for (const course of snapshot.courses) {
|
|
const base = { courseId: course.course.id, courseTitle: course.title };
|
|
|
|
if (matchesAll(course.title, terms)) {
|
|
push({ ...base, where: 'course title', targetId: course.course.id, targetKind: 'course', snippet: course.title });
|
|
}
|
|
|
|
matchBoards(course.boards, base, terms, push);
|
|
|
|
for (const lesson of course.lessons) {
|
|
const haystack = `${lesson.name}\n${lesson.text}`;
|
|
if (matchesAll(haystack, terms)) {
|
|
push({
|
|
...base,
|
|
where: lesson.text && matchesAll(lesson.text, terms) ? `lesson "${lesson.name}"` : 'lesson title',
|
|
targetId: lesson.id,
|
|
targetKind: 'lesson',
|
|
snippet: snippet(haystack, terms),
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const task of course.tasks) {
|
|
const haystack = `${task.task.name}\n${task.text}`;
|
|
if (matchesAll(haystack, terms)) {
|
|
push({ ...base, where: 'task', targetId: task.id, targetKind: 'task', snippet: snippet(haystack, terms) });
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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({
|
|
courseId: file.at.courseId,
|
|
courseTitle: file.at.courseTitle,
|
|
where: `file in ${file.at.containerTitle ?? 'course'}`,
|
|
targetId: file.record.id,
|
|
targetKind: 'file',
|
|
snippet: file.record.name,
|
|
});
|
|
}
|
|
}
|
|
|
|
return hits.slice(0, limit);
|
|
}
|