A quiz in Schulcloud is an H5P element, and a board hands over nothing but a
contentId — so a teacher's exercise was until now a line saying one exists.
The player shows a single question at a time, which makes it look like
something to step through or scrape. It is not:
`GET /api/v3/h5p-editor/params/{contentId}` returns the JSON the player is fed,
so one request holds every question, every option and which of them are
correct. (`play/{id}` is the same content plus the player's script lists: 74 kB
against 51 kB for the live quiz. Neither docs-json describes the service.)
get_h5p prints the exercise, and solutions=false keeps the options while
dropping the answers, so it can be used to ask the questions instead of
answering them. get_board names the exercise — title, question count, kinds —
rather than printing a bare id, and the crawl indexes its text, so a phrase
that exists only inside a quiz is now findable. That is the treatment pads
already get, for the same reason: it is course material and nothing else
surfaces it.
What varies is the shape inside `params`, which belongs to whichever H5P
library the teacher used. Modelled: MultiChoice, whose `behaviour.singleAnswer`
is the only honest source for "tick exactly one"; TrueFalse, whose `correct` is
the string "true"; the cloze libraries, which mark solutions inline as
`*answer:tip*`; SingleChoiceSet and Summary, which put the correct option first
and let the player shuffle; and Column. Anything else has its text harvested
and labelled unmodelled — an exercise reported as "0 questions" would be worse
than a clumsy rendering of one. The harvest skips the UI and l10n subtrees, or
a quiz reads as "Überprüfen, Wiederholen, Absenden".
Verified against this account's quiz, an H5P.QuestionSet of 20 MultiChoice
questions on a room's board: 239 tests, smoke 91/91 live-only and 93/93 with
the index.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
623 lines
26 KiB
TypeScript
623 lines
26 KiB
TypeScript
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||
import { z } from 'zod';
|
||
import type { ServerContext } from '../../context.ts';
|
||
import { SchulcloudApiError } from '../../core/client.ts';
|
||
import { formatBytes } from '../../core/extract.ts';
|
||
import { dueLabel, formatDate, heading, htmlToText, joinSections, normalizeObjectId } from '../../core/text.ts';
|
||
import { assembleBoard, type AssembledBoard, type AssembledElement } from '../../core/board.ts';
|
||
import { forEachLimited } from '../../core/crawl.ts';
|
||
import { fetchLessonPadText } from '../../core/etherpad.ts';
|
||
import type { FmListing } from '../../core/legacy-files.ts';
|
||
import { fetchLessonTaskLinks, withScrapedIds } from '../../core/lesson-page.ts';
|
||
import type {
|
||
CourseBoardResponse,
|
||
CourseTime,
|
||
FileRecord,
|
||
LegacyCourse,
|
||
LessonLinkedTask,
|
||
LessonResponse,
|
||
ResolvedTask,
|
||
} from '../../core/types.ts';
|
||
import { failure, text, toToolError } from './result.ts';
|
||
import { describeSubmission } from './submissions.ts';
|
||
|
||
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
||
|
||
export function registerContentTools(server: McpServer, context: ServerContext): void {
|
||
server.registerTool(
|
||
'get_course',
|
||
{
|
||
title: 'Get course contents',
|
||
description:
|
||
'Everything inside one course: its topics ("Themen"/lessons), tasks, and column boards, in the order ' +
|
||
'shown on the course page. Returns ids for each, which get_board, get_lesson and get_task take. ' +
|
||
'Most teaching material lives on column boards.',
|
||
inputSchema: {
|
||
courseId: z.string().describe('Course id from list_courses or get_dashboard.'),
|
||
},
|
||
annotations: READ_ONLY,
|
||
},
|
||
async ({ courseId }) => {
|
||
try {
|
||
return text(await readCourse(context, courseId));
|
||
} catch (error) {
|
||
return toToolError(error, `read course ${courseId}`);
|
||
}
|
||
},
|
||
);
|
||
|
||
server.registerTool(
|
||
'get_board',
|
||
{
|
||
title: 'Get column board',
|
||
description:
|
||
'The full contents of a column board: every column, card, text block, link and attached file, with ' +
|
||
'file ids ready for download_file. This is where course material actually lives — prefer it over ' +
|
||
'poking at cards individually.',
|
||
inputSchema: {
|
||
boardId: z.string().describe('Board id, from get_course.'),
|
||
includeFiles: z
|
||
.boolean()
|
||
.default(true)
|
||
.describe('Resolve attachments to real file records. Turn off for a faster structure-only view.'),
|
||
},
|
||
annotations: READ_ONLY,
|
||
},
|
||
async ({ boardId, includeFiles }) => {
|
||
try {
|
||
const schoolId = await context.schoolId();
|
||
const board = await assembleBoard(context.client, boardId, schoolId, {
|
||
resolveFiles: includeFiles,
|
||
resolvePads: context.config,
|
||
resolveH5p: true,
|
||
});
|
||
return text(formatBoard(board, includeFiles));
|
||
} catch (error) {
|
||
// An unpublished board 403s, while the course page lists its title
|
||
// regardless — the course-board projection does not filter drafts.
|
||
// Reporting that as "no permission" sends the reader looking for an
|
||
// access problem that does not exist; a draft is the common cause.
|
||
if (error instanceof SchulcloudApiError && error.status === 403) {
|
||
return failure(
|
||
`Board ${boardId} could not be opened (HTTP 403).\n\n` +
|
||
`The usual reason is that it is still a draft: an unpublished board is listed on ` +
|
||
`the course page with its title, but stays closed until the teacher publishes it. ` +
|
||
`Otherwise this account genuinely has no access to it.`,
|
||
);
|
||
}
|
||
return toToolError(error, `read board ${boardId}`);
|
||
}
|
||
},
|
||
);
|
||
|
||
server.registerTool(
|
||
'get_lesson',
|
||
{
|
||
title: 'Get lesson',
|
||
description:
|
||
'One topic/lesson ("Thema") from a course: its text sections, linked materials, attached files and ' +
|
||
'the tasks that belong to it. Lessons are the older content format; newer courses use column boards.',
|
||
inputSchema: {
|
||
lessonId: z.string().describe('Lesson id, from get_course.'),
|
||
},
|
||
annotations: READ_ONLY,
|
||
},
|
||
async ({ lessonId }) => {
|
||
try {
|
||
const schoolId = await context.schoolId();
|
||
const [lesson, tasks, files] = await Promise.all([
|
||
context.client.getLesson(lessonId),
|
||
context.client.getLessonTasks(lessonId).catch(() => []),
|
||
context.client
|
||
.listFiles({ storageLocationId: schoolId, parentType: 'lessons', parentId: lessonId })
|
||
.catch(() => undefined),
|
||
]);
|
||
// The task bodies carry no id, so get_task cannot be pointed at them
|
||
// without the topic page. Only paid for when the topic has tasks.
|
||
const withIds =
|
||
tasks.length > 0
|
||
? withScrapedIds(tasks, await fetchLessonTaskLinks(context.config, lesson.courseId, lessonId))
|
||
: tasks;
|
||
// Pads are fetched only when the topic actually has one: each costs a
|
||
// topic-page render to obtain the Etherpad session cookie.
|
||
const padTexts = new Map<number, string>();
|
||
await Promise.all(
|
||
(lesson.contents ?? []).map(async (entry, index) => {
|
||
if (entry.component !== 'Etherpad') return;
|
||
const url = entry.content?.url;
|
||
if (typeof url !== 'string') return;
|
||
const padText = await fetchLessonPadText(context.config, lesson.courseId, lessonId, url);
|
||
if (padText) padTexts.set(index, padText);
|
||
}),
|
||
);
|
||
return text(formatLesson(lesson, withIds, files?.data ?? [], padTexts));
|
||
} catch (error) {
|
||
return toToolError(error, `read lesson ${lessonId}`);
|
||
}
|
||
},
|
||
);
|
||
|
||
server.registerTool(
|
||
'get_task',
|
||
{
|
||
title: 'Get task',
|
||
description:
|
||
'Full detail for one task: description, due date, attached files, and **what the account handed ' +
|
||
'in** — submission id, graded state, grade, the submitted files ready for download_file, what ' +
|
||
'the user wrote, and the teacher\'s written feedback. ' +
|
||
'The API has no single-task endpoint, so this locates the task through the task lists and course ' +
|
||
'pages — pass courseId when you know it, which makes the lookup immediate instead of a scan.',
|
||
inputSchema: {
|
||
taskId: z.string().describe('Task id, from list_tasks or get_course.'),
|
||
courseId: z.string().optional().describe('Course the task belongs to. Optional; speeds up the lookup.'),
|
||
},
|
||
annotations: READ_ONLY,
|
||
},
|
||
async ({ taskId, courseId }) => {
|
||
try {
|
||
const schoolId = await context.schoolId();
|
||
const found = await findTask(context, taskId, courseId);
|
||
if (!found) {
|
||
return failure(
|
||
`Task ${taskId} was not found on any course page this account can see, nor in the task ` +
|
||
`lists. Check the id — get it from list_tasks, get_course or list_submissions.`,
|
||
);
|
||
}
|
||
const [files, submission] = await Promise.all([
|
||
context.client
|
||
.listFiles({ storageLocationId: schoolId, parentType: 'tasks', parentId: taskId })
|
||
.catch(() => undefined),
|
||
describeSubmission(context, taskId).catch(() => undefined),
|
||
]);
|
||
return text(formatTask(found, files?.data ?? [], submission));
|
||
} catch (error) {
|
||
return toToolError(error, `read task ${taskId}`);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A course's overview as Markdown: what get_course returns, and what the
|
||
* course resource carries, so an attached course reads the same as a fetched one.
|
||
*/
|
||
export async function readCourse(context: ServerContext, courseId: string): Promise<string> {
|
||
const [board, legacy, courseFiles] = await Promise.all([
|
||
context.client.getCourseBoard(courseId),
|
||
// The v3 projection carries no description, teachers, members or
|
||
// timetable; /api/v1/courses still does. Optional on purpose — it
|
||
// is a legacy route, so its absence must cost detail, not the call.
|
||
context.client.getLegacyCourse(courseId).catch(() => undefined),
|
||
// The course's file-manager area is a different store from the page.
|
||
// Teachers who only upload files there leave the page itself empty,
|
||
// and reporting "empty" then sends the reader away from the material.
|
||
context.files.list({ area: 'courses', ownerId: courseId }).catch(() => undefined),
|
||
]);
|
||
const teachers = legacy
|
||
? await context.resolveNames([...(legacy.teacherIds ?? []), ...(legacy.substitutionIds ?? [])])
|
||
: { names: [], unresolved: 0 };
|
||
return formatCourseBoard(board, legacy, teachers, courseFiles);
|
||
}
|
||
|
||
// --- task lookup -------------------------------------------------------
|
||
|
||
/**
|
||
* Finds a task by id.
|
||
*
|
||
* There is no `GET /tasks/{id}`, and the list endpoints omit `description`,
|
||
* which is only present on the course page's task element. So: use the lists
|
||
* to learn which course the task belongs to (unless told), then read the
|
||
* description off that course's page.
|
||
*/
|
||
async function findTask(context: ServerContext, taskId: string, courseId?: string): Promise<ResolvedTask | undefined> {
|
||
if (courseId) {
|
||
const fromCourse = await taskFromCourse(context, courseId, taskId);
|
||
if (fromCourse) return fromCourse;
|
||
}
|
||
|
||
const [open, finished] = await Promise.all([
|
||
context.client.listTasks({ limit: 99 }).catch(() => undefined),
|
||
context.client.listFinishedTasks({ limit: 99 }).catch(() => undefined),
|
||
]);
|
||
const listed = [...(open?.data ?? []), ...(finished?.data ?? [])].find((task) => task.id === taskId);
|
||
|
||
if (listed) {
|
||
// The list entry lacks the description; the course page has it.
|
||
if (listed.courseId) {
|
||
const enriched = await taskFromCourse(context, listed.courseId, taskId);
|
||
if (enriched) return { ...listed, ...enriched };
|
||
}
|
||
return listed;
|
||
}
|
||
|
||
// The task lists only cover what the dashboard shows, so a perfectly visible
|
||
// task can be absent from both — group-project tasks in particular. Falling
|
||
// back to scanning course pages costs ~26 requests and a few seconds, which
|
||
// is a fair price for the tool working instead of claiming the id is wrong.
|
||
const courses = await context.client.listAllCourses().catch(() => []);
|
||
let found: ResolvedTask | undefined;
|
||
await forEachLimited(courses, 6, async (course) => {
|
||
if (found) return;
|
||
const fromCourse = await taskFromCourse(context, course.id, taskId);
|
||
if (fromCourse) found = fromCourse;
|
||
});
|
||
return found;
|
||
}
|
||
|
||
async function taskFromCourse(
|
||
context: ServerContext,
|
||
courseId: string,
|
||
taskId: string,
|
||
): Promise<ResolvedTask | undefined> {
|
||
const board = await context.client.getCourseBoard(courseId).catch(() => undefined);
|
||
if (!board) return undefined;
|
||
for (const element of board.elements) {
|
||
if (element.type === 'task' && element.content.id === taskId) {
|
||
return { ...element.content, courseId, courseName: element.content.courseName ?? board.title };
|
||
}
|
||
}
|
||
|
||
// A task can hang off a topic rather than the course page, and those are not
|
||
// listed as task elements — only as a count on the topic. Without this the
|
||
// task is unreachable: not in the lists (a submitted, past-due task is in
|
||
// neither open nor finished) and not on the course page either.
|
||
for (const element of board.elements) {
|
||
if (element.type !== 'lesson' || !element.content.numberOfPublishedTasks) continue;
|
||
const links = await fetchLessonTaskLinks(context.config, courseId, element.content.id);
|
||
if (!links.some((link) => link.id === taskId)) continue;
|
||
const tasks = await context.client.getLessonTasks(element.content.id).catch(() => []);
|
||
const match = withScrapedIds(tasks, links).find((task) => task.id === taskId);
|
||
if (match) {
|
||
return { ...match, courseId, courseName: match.courseName ?? board.title, lessonName: element.content.name };
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
// --- formatting --------------------------------------------------------
|
||
|
||
function formatCourseBoard(
|
||
board: CourseBoardResponse,
|
||
legacy?: LegacyCourse,
|
||
teachers: { names: string[]; unresolved: number } = { names: [], unresolved: 0 },
|
||
courseFiles?: FmListing,
|
||
): string {
|
||
const boards: string[] = [];
|
||
const lessons: string[] = [];
|
||
const tasks: string[] = [];
|
||
|
||
for (const element of board.elements) {
|
||
if (element.type === 'column-board') {
|
||
boards.push(`- **${element.content.title}** (\`${element.content.id}\`)`);
|
||
} else if (element.type === 'lesson') {
|
||
const taskCount = element.content.numberOfPublishedTasks
|
||
? ` — ${element.content.numberOfPublishedTasks} task(s)`
|
||
: '';
|
||
const hidden = element.content.hidden ? ' [hidden]' : '';
|
||
lessons.push(`- **${element.content.name}** (\`${element.content.id}\`)${taskCount}${hidden}`);
|
||
} else if (element.type === 'task') {
|
||
const status = element.content.status.submitted > 0 ? 'submitted' : 'not submitted';
|
||
tasks.push(`- **${element.content.name}** (\`${element.content.id}\`) — ${dueLabel(element.content.dueDate)}, ${status}`);
|
||
}
|
||
}
|
||
|
||
const about = joinSections([
|
||
htmlToText(legacy?.description)?.trim() || undefined,
|
||
formatTeachers(teachers),
|
||
legacy?.userIds?.length ? `**Members:** ${legacy.userIds.length}` : undefined,
|
||
formatCourseTimes(legacy?.times),
|
||
]);
|
||
|
||
const filesSection = formatCourseFiles(board.roomId, courseFiles);
|
||
|
||
if (boards.length + lessons.length + tasks.length === 0) {
|
||
return joinSections([
|
||
heading(2, board.title),
|
||
`Course id: \`${board.roomId}\``,
|
||
about,
|
||
filesSection
|
||
? 'No boards, topics or tasks on the course page — the material is in the course files instead.'
|
||
: 'This course page is empty, and the course has no files in the file manager either.',
|
||
filesSection,
|
||
]);
|
||
}
|
||
|
||
return joinSections([
|
||
heading(2, board.title),
|
||
`Course id: \`${board.roomId}\``,
|
||
about,
|
||
boards.length > 0 && joinSections([heading(3, `Boards (${boards.length})`), boards.join('\n'), 'Read one with get_board.']),
|
||
lessons.length > 0 && joinSections([heading(3, `Topics (${lessons.length})`), lessons.join('\n'), 'Read one with get_lesson.']),
|
||
tasks.length > 0 && joinSections([heading(3, `Tasks (${tasks.length})`), tasks.join('\n'), 'Read one with get_task.']),
|
||
filesSection,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* The course's own file-manager area ("Kurs-Dateien"), when it holds anything.
|
||
*
|
||
* Only the top level is fetched — one page — so this says how much is there
|
||
* and where, rather than listing it; fs_tree does that.
|
||
*/
|
||
function formatCourseFiles(courseId: string, listing: FmListing | undefined): string | undefined {
|
||
if (!listing || listing.directories.length + listing.files.length === 0) return undefined;
|
||
const names = [...listing.directories.map((entry) => `${entry.name}/`), ...listing.files.map((entry) => entry.name)];
|
||
const shown = names.slice(0, 8).map((name) => `- ${name}`).join('\n');
|
||
return joinSections([
|
||
heading(3, 'Course files (Kurs-Dateien)'),
|
||
`${listing.directories.length} folder(s) and ${listing.files.length} file(s) at the top level, newest first:`,
|
||
shown + (names.length > 8 ? `\n- … and ${names.length - 8} more` : ''),
|
||
`See everything with fs_tree path "/courses/${courseId}", read one with fs_read.`,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Who teaches the course.
|
||
*
|
||
* A student may not read their teachers' user records, so names are often
|
||
* unavailable; say how many there are rather than printing bare ids, which
|
||
* are no use to a reader and look like a bug.
|
||
*/
|
||
function formatTeachers(teachers: { names: string[]; unresolved: number }): string | undefined {
|
||
const { names, unresolved } = teachers;
|
||
if (names.length === 0 && unresolved === 0) return undefined;
|
||
if (names.length === 0) {
|
||
return `**Taught by:** ${unresolved} teacher(s) — names are not visible to this account`;
|
||
}
|
||
const rest = unresolved > 0 ? ` and ${unresolved} more (name not visible to this account)` : '';
|
||
return `**Taught by:** ${names.join(', ')}${rest}`;
|
||
}
|
||
|
||
/**
|
||
* A course's weekly timetable.
|
||
*
|
||
* `times` is the closest thing to a calendar the API exposes — the calendar
|
||
* service itself is not part of the v3 document. `startTime` is milliseconds
|
||
* since midnight and `weekday` is 0-based from Monday, as the legacy client
|
||
* renders it.
|
||
*/
|
||
function formatCourseTimes(times: CourseTime[] | undefined): string | undefined {
|
||
if (!times || times.length === 0) return undefined;
|
||
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||
const rows = times
|
||
.slice()
|
||
.sort((a, b) => (a.weekday ?? 0) - (b.weekday ?? 0) || (a.startTime ?? 0) - (b.startTime ?? 0))
|
||
.map((slot) => {
|
||
const day = days[slot.weekday ?? 0] ?? `day ${slot.weekday}`;
|
||
const room = slot.room ? `, room ${slot.room}` : '';
|
||
return `- ${day} ${clockFromMs(slot.startTime)}${slot.duration ? `–${clockFromMs((slot.startTime ?? 0) + slot.duration)}` : ''}${room}`;
|
||
});
|
||
return joinSections([`**Weekly schedule:**`, rows.join('\n')]);
|
||
}
|
||
|
||
function clockFromMs(ms: number | undefined): string {
|
||
if (ms === undefined) return '?';
|
||
const total = Math.floor(ms / 60_000);
|
||
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
|
||
}
|
||
|
||
function formatBoard(board: AssembledBoard, includeFiles: boolean): string {
|
||
const columns = board.columns.map((column) => {
|
||
const cards = column.cards.map((card) => {
|
||
const body = card.elements
|
||
.map((element) => formatElement(element, includeFiles))
|
||
.filter(Boolean)
|
||
.join('\n');
|
||
return joinSections([heading(4, card.title), body || '_(empty card)_']);
|
||
});
|
||
return joinSections([heading(3, column.title), cards.length > 0 ? cards.join('\n\n') : '_(no cards)_']);
|
||
});
|
||
|
||
const summary =
|
||
`Board id: \`${board.id}\`` +
|
||
(board.context ? ` — in ${board.context.type} \`${board.context.id}\`` : '') +
|
||
(includeFiles ? ` — ${board.fileCount} attached file(s)` : '');
|
||
|
||
return joinSections([
|
||
heading(2, board.title),
|
||
summary,
|
||
columns.length > 0 ? columns.join('\n\n') : '_(no columns)_',
|
||
includeFiles && board.fileCount > 0 ? 'Read any attachment with download_file using its file id.' : undefined,
|
||
]);
|
||
}
|
||
|
||
/** Indents a pad's body so it reads as quoted content, not as board structure. */
|
||
function indent(body: string): string {
|
||
return body
|
||
.split('\n')
|
||
.map((line) => ` > ${line}`.trimEnd())
|
||
.join('\n');
|
||
}
|
||
|
||
function formatElement(element: AssembledElement, includeFiles: boolean): string {
|
||
switch (element.type) {
|
||
case 'richText': {
|
||
const body = htmlToText(element.text);
|
||
return body ? body : '';
|
||
}
|
||
case 'link': {
|
||
const label = element.text?.trim();
|
||
if (!element.url) return '';
|
||
const head = `- Link: ${label && label !== element.url ? `${label} — ${element.url}` : element.url}`;
|
||
const note = htmlToText(element.description)?.trim();
|
||
return note ? `${head}\n${indent(note)}` : head;
|
||
}
|
||
case 'file':
|
||
case 'fileFolder':
|
||
case 'drawing': {
|
||
const caption = element.text ? ` — caption: ${element.text}` : '';
|
||
// Alt text describes the picture itself, so it belongs on the line
|
||
// whether or not the file records could be listed.
|
||
const alt = element.alternativeText?.trim() ? ` — alt: ${element.alternativeText.trim()}` : '';
|
||
const note = element.description?.trim() ? ` — ${element.description.trim()}` : '';
|
||
const extra = `${caption}${alt}${note}`;
|
||
if (!includeFiles) return `- ${element.type} element \`${element.id}\`${extra}`;
|
||
if (element.fileError) return `- ${element.type} element \`${element.id}\` — could not list files (${element.fileError})`;
|
||
if (element.files.length === 0) return `- ${element.type} element \`${element.id}\` — no files${extra}`;
|
||
return element.files.map((file) => `- ${formatFileLine(file)}${extra}`).join('\n');
|
||
}
|
||
case 'collaborativeTextEditor': {
|
||
const title = element.text ? ` — ${element.text}` : '';
|
||
// The board API returns these with empty content; the text comes from
|
||
// the pad itself (core/etherpad.ts). Absent means empty or unreadable,
|
||
// which for a pad is usually "nobody has written in it yet".
|
||
if (!element.padText) {
|
||
return `- Collaborative text document \`${element.id}\`${title} (empty, or its contents could not be read)`;
|
||
}
|
||
return [`- Collaborative text document \`${element.id}\`${title}:`, indent(element.padText)].join('\n');
|
||
}
|
||
case 'externalTool': {
|
||
// The configured-tool id is what `api_get /api/v3/tools/...` needs to
|
||
// say which tool this actually is; without it the element is opaque.
|
||
const tool = element.contextExternalToolId
|
||
? ` — configured tool \`${element.contextExternalToolId}\``
|
||
: '';
|
||
return `- External tool${element.text ? `: ${element.text}` : ''} \`${element.id}\`${tool}`;
|
||
}
|
||
case 'videoConference':
|
||
return `- Video conference${element.text ? `: ${element.text}` : ''} \`${element.id}\``;
|
||
case 'h5p': {
|
||
// Schulcloud has no quiz of its own: interactive exercises are H5P, and
|
||
// this id is the only way to reach the content behind one.
|
||
const content = element.h5pContentId ? ` \`${element.h5pContentId}\`` : '';
|
||
if (!element.h5p) {
|
||
return `- H5P interactive content \`${element.id}\`${content ? ` — H5P content${content}` : ''}`;
|
||
}
|
||
// Summarised rather than inlined: a question set runs to twenty
|
||
// questions with their options, which would bury the rest of the board.
|
||
// get_h5p prints them, and search reaches their text either way.
|
||
const quiz = element.h5p;
|
||
const kinds = [...new Set(quiz.questions.map((question) => question.kind))].join(', ');
|
||
const count = quiz.questions.length;
|
||
const unread = quiz.unmodelled.length > 0 ? `, ${quiz.unmodelled.length} part(s) this server cannot model` : '';
|
||
return (
|
||
`- **H5P exercise: ${quiz.title}** — ${count} question(s)${kinds ? ` (${kinds})` : ''}${unread}, ` +
|
||
`content${content} — all of it with get_h5p`
|
||
);
|
||
}
|
||
case 'deleted': {
|
||
// Saying what it was beats "(deleted element)": the title often names
|
||
// the material a student is looking for and cannot find.
|
||
const was = element.deletedElementType ? ` ${element.deletedElementType}` : '';
|
||
const title = element.text ? `: ${element.text}` : '';
|
||
return `- _(deleted${was} element${title})_`;
|
||
}
|
||
default:
|
||
return `- ${element.type} element \`${element.id}\``;
|
||
}
|
||
}
|
||
|
||
export function formatFileLine(file: FileRecord): string {
|
||
const blocked = file.securityCheckStatus === 'blocked' ? ' **[virus scan: blocked]**' : '';
|
||
const pending = file.securityCheckStatus === 'pending' ? ' _[virus scan pending]_' : '';
|
||
return `File: **${file.name}** (\`${file.id}\`, ${file.mimeType}, ${formatBytes(file.size)})${blocked}${pending}`;
|
||
}
|
||
|
||
function formatLesson(
|
||
lesson: LessonResponse,
|
||
tasks: LessonLinkedTask[],
|
||
files: FileRecord[],
|
||
padTexts: Map<number, string> = new Map(),
|
||
): string {
|
||
const sections = (lesson.contents ?? []).map((entry, index) => {
|
||
const title = entry.title?.trim();
|
||
const component = entry.component ?? 'unknown';
|
||
const hidden = entry.hidden ? ' [hidden]' : '';
|
||
const body = formatLessonComponent(component, entry.content ?? {}, padTexts.get(index));
|
||
return joinSections([heading(4, `${title || component}${hidden}`), body || `_(${component} content, nothing to show)_`]);
|
||
});
|
||
|
||
const materials = (lesson.materials ?? []).map((material) => {
|
||
const id = normalizeObjectId(material.id);
|
||
return `- ${material.title ?? 'Untitled material'}${material.url ? ` — ${material.url}` : ''}${id ? ` (\`${id}\`)` : ''}`;
|
||
});
|
||
|
||
return joinSections([
|
||
heading(2, lesson.name),
|
||
`Lesson id: \`${lesson.id}\` — in course \`${lesson.courseId}\`${lesson.hidden ? ' — hidden' : ''}`,
|
||
sections.length > 0 ? joinSections([heading(3, 'Contents'), sections.join('\n\n')]) : '_(no text contents)_',
|
||
materials.length > 0 && joinSections([heading(3, 'Linked materials'), materials.join('\n')]),
|
||
files.length > 0 &&
|
||
joinSections([heading(3, `Attached files (${files.length})`), files.map((file) => `- ${formatFileLine(file)}`).join('\n')]),
|
||
tasks.length > 0 &&
|
||
joinSections([
|
||
heading(3, `Tasks in this lesson (${tasks.length})`),
|
||
tasks
|
||
.map((task) => {
|
||
const id = task.id ? ` (\`${task.id}\`)` : '';
|
||
return `- **${task.name}**${id} — ${dueLabel(task.dueDate)}`;
|
||
})
|
||
.join('\n'),
|
||
]),
|
||
]);
|
||
}
|
||
|
||
function formatLessonComponent(
|
||
component: string,
|
||
content: Record<string, unknown>,
|
||
padText?: string,
|
||
): string {
|
||
if (component === 'text' && typeof content.text === 'string') return htmlToText(content.text);
|
||
if (component === 'resources' && Array.isArray(content.resources)) {
|
||
return content.resources
|
||
.map((resource) => {
|
||
const entry = resource as { title?: string; url?: string; description?: string };
|
||
const note = entry.description?.trim();
|
||
const head = `- ${entry.title ?? 'Resource'}${entry.url ? ` — ${entry.url}` : ''}`;
|
||
return note ? `${head}\n${indent(note)}` : head;
|
||
})
|
||
.join('\n');
|
||
}
|
||
// A topic's Etherpad: the same collaborative document a column board can
|
||
// hold, reached by a stored url instead of an element id. The url is data
|
||
// and may name another deployment, in which case the text is unavailable
|
||
// and the link is the honest answer.
|
||
if (component === 'Etherpad') {
|
||
const url = typeof content.url === 'string' ? content.url : undefined;
|
||
const note = typeof content.description === 'string' ? content.description.trim() : '';
|
||
const lines = [url ? `- Collaborative text document — ${url}` : '- Collaborative text document'];
|
||
if (note) lines.push(indent(note));
|
||
if (padText) lines.push(indent(padText));
|
||
else if (url) lines.push(indent('_(empty, or its contents could not be read)_'));
|
||
return lines.join('\n');
|
||
}
|
||
// A GeoGebra applet. Only the material id is stored, so name it and let the
|
||
// reader follow it rather than rendering an empty section.
|
||
if (component === 'geoGebra' && typeof content.materialId === 'string') {
|
||
return `- GeoGebra applet \`${content.materialId}\` — https://www.geogebra.org/m/${content.materialId}`;
|
||
}
|
||
if (typeof content.url === 'string') return `- ${content.url}`;
|
||
if (typeof content.title === 'string') return content.title;
|
||
return '';
|
||
}
|
||
|
||
function formatTask(task: ResolvedTask, files: FileRecord[], submission?: string): string {
|
||
const description = htmlToText(task.description);
|
||
return joinSections([
|
||
heading(2, task.name),
|
||
[
|
||
`- Task id: \`${task.id}\``,
|
||
task.courseName ? `- Course: ${task.courseName}${task.courseId ? ` (\`${task.courseId}\`)` : ''}` : undefined,
|
||
task.lessonName ? `- Topic: ${task.lessonName}` : undefined,
|
||
`- Available from: ${formatDate(task.availableDate)}`,
|
||
`- Due: ${dueLabel(task.dueDate)}`,
|
||
// Absent for a task found through a topic: that projection reports no
|
||
// counts. The submission section below carries the authoritative state.
|
||
task.status
|
||
? `- Submitted: ${task.status.submitted}/${task.status.maxSubmissions}${task.status.graded > 0 ? ', graded' : ''}`
|
||
: undefined,
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n'),
|
||
description ? joinSections([heading(3, 'Description'), description]) : '_(no description)_',
|
||
files.length > 0
|
||
? joinSections([
|
||
heading(3, `Attached files (${files.length})`),
|
||
files.map((file) => `- ${formatFileLine(file)}`).join('\n'),
|
||
'Read one with download_file.',
|
||
])
|
||
: undefined,
|
||
submission,
|
||
]);
|
||
}
|