import type { CrawledBoard, Snapshot } from './crawl.ts'; import { h5pSearchText } from './h5p.ts'; import { noteSearchText, noteSections } from './notes.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' | 'note'; 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); // Same corpus as the index, or a live search would disagree with it. if (element.h5p) parts.push(h5pSearchText(element.h5p)); 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); } // The user's own notes. `courseId` is the subject rather than an id: nothing // follows a note back to a course, and the subject is what makes the hit // readable — "Deutsch — my note" rather than a bare path. for (const note of snapshot.notes) { // Per lesson where the note has lessons, exactly as the index does it — // otherwise fresh=true would report "Monday" where the index reports // "Deutsch, Monday", and the two paths would disagree about the same file. const sections = noteSections(note); if (sections.length > 0) { for (const section of sections) { const haystack = [section.heading, section.text].filter(Boolean).join('\n'); if (!matchesAll(haystack, terms)) continue; hits.push({ courseId: note.courseId ?? '', courseTitle: section.subject ?? note.subject ?? 'Notizen', where: `my own note, ${section.heading}${note.date ? `, ${note.date}` : ''}`, targetId: note.path, targetKind: 'note', snippet: snippet(haystack, terms), }); } continue; } const haystack = noteSearchText(note); if (!matchesAll(haystack, terms)) continue; hits.push({ courseId: note.courseId ?? '', courseTitle: note.subject ?? 'Notizen', where: `my own note${note.date ? `, ${note.date}` : ''}`, targetId: note.path, targetKind: 'note', snippet: snippet(haystack, terms), }); } 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); }