Files
Schulcloud-MCP/src/mcp/tools/content.ts
MechaCat02 a0cef532c6 Read the quizzes behind H5P elements
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>
2026-09-17 21:39:19 +02:00

623 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
]);
}