Close the gaps an audit of courses, tasks, files and grades turned up
Every area — courses, rooms, boards, topics, tasks, files, quizzes, teams,
groups, submissions, grades — was checked for data the instance has and the
tools did not show.
Grades and feedback. A teacher's /homework page is a different page from a
student's: grade and comment live in the grading form, one block per
submission, so a teacher account reported every graded submission as having
neither. parseTeacherGrading reads the form, and list_submissions can now
include the written feedback and who handed the work in.
Names. /api/v1 is partly served: courses, users and classes survive in the
deployment's ingress table, and users/{id} is the only route from an id to a
name. Submitters, file creators and course teachers resolve through it, and
degrade to "not visible to this account" where a student may not read them.
Courses, rooms and classes. get_course adds the description, teachers,
member count and weekly timetable from /api/v1/courses. list_classes is new.
get_room reports what the account may do — allowedOperations is an object of
booleans, not the list it was typed as — and applicants and invitation links
where it may manage them.
Board and topic content. Link descriptions, image alt text, drawing and
video-conference titles, the ids behind external tools and H5P content (the
only thing resembling a quiz), and what a deleted element used to be. Topic
Etherpad pads are read like board pads, and htmlToText keeps table columns
apart and drops template indentation.
Files. A scan with no text layer falls back to the preview endpoint, whose
width and outputFormat are undocumented enums, so Claude gets a picture of
the page; list_files reports counts and sizes. Teams stay documented as
unreadable at any API version; their files come later.
What the crawl missed. Tasks attached to topics (18 of 60 on the live
account), each course's own file area, and — behind INDEX_PERSONAL_FILES —
personal files and submissions with their grade comments, so search and
what_changed cover grading. A submission hit points at get_task.
The local instance's preview profile gets an ImageMagick policy that allows
the coders its 7.1.2 build needs; the image's own denies them all.
110 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,16 @@ export interface AssembledElement {
|
||||
fileError?: string;
|
||||
/** What a class actually wrote in a collaborativeTextEditor (Etherpad) pad. */
|
||||
padText?: string;
|
||||
/** Longer body text: a link's description, a drawing's, a deleted element's. */
|
||||
description?: string;
|
||||
/** Image alt text — often the only description a picture carries. */
|
||||
alternativeText?: string;
|
||||
/** H5P content id: the handle onto interactive content (quizzes and the like). */
|
||||
h5pContentId?: string;
|
||||
/** Which configured tool an externalTool element launches. */
|
||||
contextExternalToolId?: string;
|
||||
/** What a deleted element used to be. */
|
||||
deletedElementType?: string;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -120,14 +130,46 @@ function buildElement(element: ContentElement): AssembledElement {
|
||||
if (element.type === 'link') {
|
||||
if (typeof content.url === 'string') assembled.url = content.url;
|
||||
if (typeof content.title === 'string') assembled.text = content.title;
|
||||
// A link's description is where the teacher says why it is worth opening.
|
||||
if (typeof content.description === 'string') assembled.description = content.description;
|
||||
}
|
||||
if ((element.type === 'file' || element.type === 'fileFolder') && typeof content.caption === 'string') {
|
||||
const caption = content.caption.trim();
|
||||
if (caption) assembled.text = caption;
|
||||
}
|
||||
// For an image this is frequently the only text describing what it shows,
|
||||
// and it is the one field a screen-reader user is guaranteed to get.
|
||||
if (element.type === 'file' && typeof content.alternativeText === 'string') {
|
||||
assembled.alternativeText = content.alternativeText;
|
||||
}
|
||||
if (element.type === 'fileFolder' && typeof content.title === 'string') {
|
||||
const title = content.title.trim();
|
||||
if (title) assembled.text = title;
|
||||
}
|
||||
if (element.type === 'drawing' && typeof content.description === 'string') {
|
||||
assembled.description = content.description;
|
||||
}
|
||||
if (element.type === 'collaborativeTextEditor' || element.type === 'externalTool') {
|
||||
if (typeof content.title === 'string') assembled.text = content.title;
|
||||
}
|
||||
// videoConference carries a title too; dropping it left the element rendered
|
||||
// as a bare id with no hint of which meeting it is.
|
||||
if (element.type === 'videoConference' && typeof content.title === 'string') {
|
||||
assembled.text = content.title;
|
||||
}
|
||||
if (element.type === 'externalTool' && typeof content.contextExternalToolId === 'string') {
|
||||
assembled.contextExternalToolId = content.contextExternalToolId;
|
||||
}
|
||||
// The only handle onto H5P content — quizzes and other interactive material
|
||||
// reach the board this way, and without the id there is nothing to follow.
|
||||
if (element.type === 'h5p' && typeof content.contentId === 'string') {
|
||||
assembled.h5pContentId = content.contentId;
|
||||
}
|
||||
if (element.type === 'deleted') {
|
||||
if (typeof content.title === 'string') assembled.text = content.title;
|
||||
if (typeof content.description === 'string') assembled.description = content.description;
|
||||
if (typeof content.deletedElementType === 'string') assembled.deletedElementType = content.deletedElementType;
|
||||
}
|
||||
|
||||
return assembled;
|
||||
}
|
||||
|
||||
@@ -3,19 +3,27 @@ import type {
|
||||
BoardContext,
|
||||
BoardSkeleton,
|
||||
CardResponse,
|
||||
ClassItem,
|
||||
CourseBoardResponse,
|
||||
CourseMetadata,
|
||||
DashboardResponse,
|
||||
FileParentType,
|
||||
FileRecord,
|
||||
GroupItem,
|
||||
LegacyCourse,
|
||||
LegacyUser,
|
||||
LessonResponse,
|
||||
MeResponse,
|
||||
NewsResponse,
|
||||
Paginated,
|
||||
ParentFileStats,
|
||||
PreviewWidth,
|
||||
SubmissionStatus,
|
||||
LessonLinkedTask,
|
||||
RoomApplicant,
|
||||
RoomBoardItem,
|
||||
RoomDetails,
|
||||
RoomInvitationLink,
|
||||
RoomItem,
|
||||
RoomMember,
|
||||
TaskContent,
|
||||
@@ -27,6 +35,9 @@ import type {
|
||||
*/
|
||||
export const MAX_IDS_PER_QUERY = 20;
|
||||
|
||||
/** The only output format the preview endpoint accepts; anything else is a 400. */
|
||||
const PREVIEW_OUTPUT_FORMAT = 'image/webp';
|
||||
|
||||
/** Statuses worth retrying: transient by definition, and every call here is a GET. */
|
||||
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
|
||||
const MAX_RETRIES = 3;
|
||||
@@ -365,6 +376,27 @@ export class SchulcloudClient {
|
||||
return body.data ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* People waiting to be let into a room, and the room's invitation links.
|
||||
*
|
||||
* Both are room-admin surface: a viewer gets 403, which is ordinary rather
|
||||
* than exceptional. `allowedOperations` on the room says which of these the
|
||||
* account may ask for, so callers can skip the ones that would be refused.
|
||||
*/
|
||||
async listRoomApplicants(roomId: string): Promise<RoomApplicant[]> {
|
||||
const body = await this.getJson<{ data?: RoomApplicant[] }>(
|
||||
`/api/v3/rooms/${encodeURIComponent(roomId)}/applicants`,
|
||||
);
|
||||
return body.data ?? [];
|
||||
}
|
||||
|
||||
async listRoomInvitationLinks(roomId: string): Promise<RoomInvitationLink[]> {
|
||||
const body = await this.getJson<{ data?: RoomInvitationLink[] }>(
|
||||
`/api/v3/rooms/${encodeURIComponent(roomId)}/room-invitation-links`,
|
||||
);
|
||||
return body.data ?? [];
|
||||
}
|
||||
|
||||
// --- column boards ---------------------------------------------------
|
||||
|
||||
getBoardSkeleton(boardId: string): Promise<BoardSkeleton> {
|
||||
@@ -441,6 +473,83 @@ export class SchulcloudClient {
|
||||
limit: clampPageSize(params.limit),
|
||||
});
|
||||
}
|
||||
|
||||
/** File count and total bytes under one parent, without listing the records. */
|
||||
getParentFileStats(parentType: FileParentType, parentId: string): Promise<ParentFileStats> {
|
||||
return this.getJson<ParentFileStats>(
|
||||
`/api/v3/file/stats/${parentType}/${encodeURIComponent(parentId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A rasterised preview of one file.
|
||||
*
|
||||
* The reason this exists: many course PDFs are image-only scans, so text
|
||||
* extraction legitimately yields nothing and their contents are otherwise
|
||||
* unreadable. A preview is a picture of the page, which Claude can read
|
||||
* directly. Only meaningful when the record's `previewStatus` is
|
||||
* `preview_possible`; anything else 404s or returns the placeholder.
|
||||
*/
|
||||
async getFilePreview(
|
||||
record: Pick<FileRecord, 'id' | 'name'>,
|
||||
width?: PreviewWidth,
|
||||
): Promise<DownloadedFile> {
|
||||
// Two traps here, both of which answer with a 400 that names the value but
|
||||
// not the permitted set:
|
||||
// - `width` is an enum (50 | 150 | 500), not a free number;
|
||||
// - `outputFormat` accepts only `image/webp`. Omitting it is worse than
|
||||
// wrong: the preview is then rendered in the *source* format, so a PDF
|
||||
// comes back as a PDF and the whole point — a picture of the page — is
|
||||
// lost.
|
||||
const query = new URLSearchParams({ outputFormat: PREVIEW_OUTPUT_FORMAT });
|
||||
if (width) query.set('width', String(width));
|
||||
const path =
|
||||
`/api/v3/file/preview/${encodeURIComponent(record.id)}/${encodeURIComponent(record.name)}` +
|
||||
`?${query.toString()}`;
|
||||
const file = await this.getBytes(path, record.name);
|
||||
|
||||
// The response labels itself `webp` rather than `image/webp`, which no
|
||||
// image consumer would accept. We asked for the format, so we know it.
|
||||
return file.mimeType.startsWith('image/') ? file : { ...file, mimeType: PREVIEW_OUTPUT_FORMAT };
|
||||
}
|
||||
|
||||
// --- legacy /api/v1 ---------------------------------------------------
|
||||
//
|
||||
// Exactly three legacy routes survive in the deployment's ingress table
|
||||
// (dof_app_deploy .../all/x_ingress.yml): courses, users and classes. They
|
||||
// are production surface, not a leftover — the table even notes why each
|
||||
// one is still needed. Everything else under /api/v1 is unrouted and 404s,
|
||||
// so do not reach for it.
|
||||
|
||||
/** One course with the fields v3 drops: description, members, timetable. */
|
||||
getLegacyCourse(courseId: string): Promise<LegacyCourse> {
|
||||
return this.getJson<LegacyCourse>(`/api/v1/courses/${encodeURIComponent(courseId)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* One user's name.
|
||||
*
|
||||
* The only id-to-name mapping available: submission `submitters`, file
|
||||
* `creatorId` and course `teacherIds` are all bare ids, and no v3 route
|
||||
* resolves them for a non-admin.
|
||||
*/
|
||||
getLegacyUser(userId: string): Promise<LegacyUser> {
|
||||
return this.getJson<LegacyUser>(`/api/v1/users/${encodeURIComponent(userId)}`);
|
||||
}
|
||||
|
||||
// --- groups and classes ------------------------------------------------
|
||||
|
||||
/** Classes ("Klassen") this account belongs to, with teacher names. */
|
||||
async listClasses(): Promise<ClassItem[]> {
|
||||
const body = await this.getJson<Paginated<ClassItem>>('/api/v3/groups/class', { limit: MAX_PAGE_SIZE });
|
||||
return body.data ?? [];
|
||||
}
|
||||
|
||||
/** Groups this account belongs to — room membership groups, classes, courses. */
|
||||
async listGroups(): Promise<GroupItem[]> {
|
||||
const body = await this.getJson<Paginated<GroupItem>>('/api/v3/groups', { limit: MAX_PAGE_SIZE });
|
||||
return body.data ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Config } from '../config.ts';
|
||||
import { assembleBoard, type AssembledBoard } from './board.ts';
|
||||
import type { SchulcloudClient } from './client.ts';
|
||||
import { fetchHomeworkPage } from './homework-page.ts';
|
||||
import { fetchLessonTaskLinks, withScrapedIds } from './lesson-page.ts';
|
||||
import { htmlToText, normalizeObjectId } from './text.ts';
|
||||
import type { CourseMetadata, FileRecord, TaskContent } from './types.ts';
|
||||
import type { CourseMetadata, FileParentType, FileRecord, TaskContent } from './types.ts';
|
||||
|
||||
/**
|
||||
* Walks an account's entire content tree and returns it as one snapshot.
|
||||
@@ -37,12 +39,34 @@ export interface Breadcrumb {
|
||||
|
||||
export interface CrawledFile {
|
||||
record: FileRecord;
|
||||
/** Board element, lesson or task this file hangs off. */
|
||||
parentType: 'boardnodes' | 'lessons' | 'tasks';
|
||||
/** What this file hangs off. */
|
||||
parentType: FileParentType;
|
||||
parentId: string;
|
||||
at: Breadcrumb;
|
||||
}
|
||||
|
||||
/**
|
||||
* One submission, with whatever the page could tell us about its grading.
|
||||
*
|
||||
* Indexed so that "what did the teacher say about X" and "what was graded this
|
||||
* week" are answerable at all: the submission endpoints carry no text and no
|
||||
* timestamps, so without this the whole grading surface is invisible to search
|
||||
* and to what_changed.
|
||||
*/
|
||||
export interface CrawledSubmission {
|
||||
id: string;
|
||||
taskId: string;
|
||||
taskName: string;
|
||||
courseId: string;
|
||||
courseTitle: string;
|
||||
isSubmitted: boolean;
|
||||
isGraded: boolean;
|
||||
grade?: number | null;
|
||||
gradeComment?: string;
|
||||
submittedText?: string;
|
||||
submitterIds: string[];
|
||||
}
|
||||
|
||||
export interface CrawledBoard {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -97,6 +121,8 @@ export interface Snapshot {
|
||||
/** Rooms ("Räume"), a separate space from courses. */
|
||||
rooms: CrawledRoom[];
|
||||
files: CrawledFile[];
|
||||
/** Populated only when `includePersonalFiles` is set; see that option. */
|
||||
submissions: CrawledSubmission[];
|
||||
/**
|
||||
* Anything that could not be read, with the reason. Boards appear here too:
|
||||
* a board that fails must not vanish silently, or the index quietly loses
|
||||
@@ -107,12 +133,23 @@ export interface Snapshot {
|
||||
|
||||
export interface CrawlOptions {
|
||||
schoolId: string;
|
||||
/** The account's own user id — needed to reach its personal files. */
|
||||
userId?: string;
|
||||
/** Restrict to these courses. Omit for everything the account can see. */
|
||||
courseIds?: string[];
|
||||
/** Fetch lesson bodies too. Costs one request per lesson. */
|
||||
includeLessonContents?: boolean;
|
||||
/** Resolve board file elements to file records. */
|
||||
includeFiles?: boolean;
|
||||
/**
|
||||
* Also index the account's personal files and its submitted / returned work.
|
||||
*
|
||||
* Off by default because of what it costs: submissions are per task, so this
|
||||
* adds roughly three requests per task on top of a crawl that is already the
|
||||
* expensive part of this server. Worth it when you want "what did I write
|
||||
* about X" to be searchable, which is otherwise impossible.
|
||||
*/
|
||||
includePersonalFiles?: boolean;
|
||||
/**
|
||||
* Read the text of collaborative text editor (Etherpad) pads, which needs a
|
||||
* second credentialled hop outside the API. Omit to leave pads unread.
|
||||
@@ -133,14 +170,30 @@ export async function crawl(client: SchulcloudClient, options: CrawlOptions): Pr
|
||||
|
||||
const crawled: CrawledCourse[] = [];
|
||||
const files: CrawledFile[] = [];
|
||||
const submissions: CrawledSubmission[] = [];
|
||||
const failures: { courseId: string; boardId?: string; reason: string }[] = [];
|
||||
let done = 0;
|
||||
|
||||
// Personal files ("Meine Dateien") hang off the user, not off any course, so
|
||||
// nothing in the course walk would ever reach them. One request, and only on
|
||||
// a full crawl — a per-course refresh has no business rewriting them.
|
||||
if (includeFiles && options.includePersonalFiles && !options.courseIds && options.userId) {
|
||||
for (const record of await listFiles(client, options.schoolId, 'users', options.userId)) {
|
||||
files.push({
|
||||
record,
|
||||
parentType: 'users',
|
||||
parentId: options.userId,
|
||||
at: { courseId: '', courseTitle: 'My files' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await forEachLimited(courses, options.courseConcurrency ?? 5, async (course) => {
|
||||
try {
|
||||
const result = await crawlCourse(client, course, options, includeFiles, includeLessons);
|
||||
crawled.push(result.course);
|
||||
files.push(...result.files);
|
||||
submissions.push(...result.submissions);
|
||||
failures.push(...result.failures);
|
||||
} catch (error) {
|
||||
failures.push({ courseId: course.id, reason: error instanceof Error ? error.message : String(error) });
|
||||
@@ -159,8 +212,17 @@ export async function crawl(client: SchulcloudClient, options: CrawlOptions): Pr
|
||||
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));
|
||||
submissions.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
return { crawledAt: new Date(), schoolId: options.schoolId, courses: crawled, rooms, files, failures };
|
||||
return {
|
||||
crawledAt: new Date(),
|
||||
schoolId: options.schoolId,
|
||||
courses: crawled,
|
||||
rooms,
|
||||
files,
|
||||
submissions,
|
||||
failures,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -281,10 +343,16 @@ async function crawlCourse(
|
||||
options: CrawlOptions,
|
||||
includeFiles: boolean,
|
||||
includeLessons: boolean,
|
||||
): Promise<{ course: CrawledCourse; files: CrawledFile[]; failures: { courseId: string; boardId: string; reason: string }[] }> {
|
||||
): Promise<{
|
||||
course: CrawledCourse;
|
||||
files: CrawledFile[];
|
||||
submissions: CrawledSubmission[];
|
||||
failures: { courseId: string; boardId: string; reason: string }[];
|
||||
}> {
|
||||
const page = await client.getCourseBoard(course.id);
|
||||
const title = page.title || course.title;
|
||||
const files: CrawledFile[] = [];
|
||||
const submissions: CrawledSubmission[] = [];
|
||||
const failures: { courseId: string; boardId: string; reason: string }[] = [];
|
||||
|
||||
const boards: CrawledBoard[] = [];
|
||||
@@ -307,6 +375,9 @@ async function crawlCourse(
|
||||
at: { courseId: course.id, courseTitle: title, containerTitle: element.content.name },
|
||||
});
|
||||
}
|
||||
if (options.includePersonalFiles) {
|
||||
await collectSubmissions(client, options, course.id, title, element.content, files, submissions);
|
||||
}
|
||||
}
|
||||
} else if (element.type === 'lesson') {
|
||||
const lesson: CrawledLesson = {
|
||||
@@ -333,6 +404,45 @@ async function crawlCourse(
|
||||
}
|
||||
}
|
||||
lessons.push(lesson);
|
||||
|
||||
// Tasks attached to a topic are not task elements on the course page,
|
||||
// so nothing above reaches them — and once past due they are absent
|
||||
// from both task lists too. On the account this was built for that is
|
||||
// 18 of 60 tasks: without this they are unsearchable and their grades
|
||||
// are invisible. The ids only exist on the topic page (lesson-page.ts).
|
||||
if (options.config && element.content.numberOfPublishedTasks) {
|
||||
const [linked, links] = await Promise.all([
|
||||
client.getLessonTasks(element.content.id).catch(() => []),
|
||||
fetchLessonTaskLinks(options.config, course.id, element.content.id),
|
||||
]);
|
||||
for (const linkedTask of withScrapedIds(linked, links)) {
|
||||
if (!linkedTask.id) continue;
|
||||
const body = htmlToText(linkedTask.description);
|
||||
const asTask: TaskContent = {
|
||||
id: linkedTask.id,
|
||||
name: linkedTask.name,
|
||||
courseId: course.id,
|
||||
courseName: title,
|
||||
lessonName: element.content.name,
|
||||
description: linkedTask.description,
|
||||
dueDate: linkedTask.dueDate ?? null,
|
||||
availableDate: linkedTask.availableDate,
|
||||
status: {
|
||||
submitted: 0,
|
||||
maxSubmissions: 0,
|
||||
graded: 0,
|
||||
isDraft: false,
|
||||
isSubstitutionTeacher: false,
|
||||
isFinished: false,
|
||||
},
|
||||
};
|
||||
tasks.push({ id: linkedTask.id, courseId: course.id, task: asTask, text: body });
|
||||
if (includeFiles && options.includePersonalFiles) {
|
||||
await collectSubmissions(client, options, course.id, title, asTask, files, submissions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (includeFiles) {
|
||||
for (const record of await listFiles(client, options.schoolId, 'lessons', element.content.id)) {
|
||||
files.push({
|
||||
@@ -346,16 +456,94 @@ async function crawlCourse(
|
||||
}
|
||||
}
|
||||
|
||||
// The course's own file area ("Dateien" on the course page). One request per
|
||||
// course, and previously invisible: these files were reachable with
|
||||
// list_files but never indexed, so search and `schulcloud sync` missed them.
|
||||
if (includeFiles) {
|
||||
for (const record of await listFiles(client, options.schoolId, 'courses', course.id)) {
|
||||
files.push({
|
||||
record,
|
||||
parentType: 'courses',
|
||||
parentId: course.id,
|
||||
at: { courseId: course.id, courseTitle: title, containerTitle: 'Course files' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
return { course: { course, title, boards, lessons, tasks }, files, submissions, failures };
|
||||
}
|
||||
|
||||
/**
|
||||
* What was handed in for one task, and what the teacher handed back.
|
||||
*
|
||||
* Both hang off the *submission* id, which is only obtainable from the status
|
||||
* endpoint — there is no submissions list. Note the asymmetry the file service
|
||||
* has here: listing with `parentType: 'gradings'` returns records whose own
|
||||
* `parentType` is `submissions`, so each record is filed under what it says it
|
||||
* is rather than under the path it was asked for. Without that split a
|
||||
* student's own upload would be indexed as teacher feedback.
|
||||
*/
|
||||
async function collectSubmissions(
|
||||
client: SchulcloudClient,
|
||||
options: CrawlOptions,
|
||||
courseId: string,
|
||||
courseTitle: string,
|
||||
task: TaskContent,
|
||||
files: CrawledFile[],
|
||||
submissions: CrawledSubmission[],
|
||||
): Promise<void> {
|
||||
const statuses = await client.listSubmissionStatuses(task.id).catch(() => []);
|
||||
if (statuses.length === 0) return;
|
||||
|
||||
// The grade comment and the submitted text exist only on the rendered page,
|
||||
// so one fetch per task covers every submission on it.
|
||||
const page = options.config
|
||||
? await fetchHomeworkPage(options.config, task.id).catch(() => undefined)
|
||||
: undefined;
|
||||
|
||||
for (const status of statuses) {
|
||||
const grading = page?.grading.find((entry) => entry.submissionId === status.id);
|
||||
submissions.push({
|
||||
id: status.id,
|
||||
taskId: task.id,
|
||||
taskName: task.name,
|
||||
courseId,
|
||||
courseTitle,
|
||||
isSubmitted: status.isSubmitted,
|
||||
isGraded: status.isGraded,
|
||||
grade: status.grade ?? grading?.gradePercent ?? null,
|
||||
gradeComment: grading?.gradeComment ?? page?.own?.gradeComment,
|
||||
submittedText: page?.own?.submittedText,
|
||||
submitterIds: status.submitters,
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of statuses) {
|
||||
for (const parentType of ['submissions', 'gradings'] as const) {
|
||||
for (const record of await listFiles(client, options.schoolId, parentType, status.id)) {
|
||||
files.push({
|
||||
record,
|
||||
parentType: record.parentType ?? parentType,
|
||||
parentId: status.id,
|
||||
at: {
|
||||
courseId,
|
||||
courseTitle,
|
||||
containerTitle: task.name,
|
||||
cardTitle: record.parentType === 'gradings' ? 'Returned by the teacher' : 'Handed in',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listFiles(
|
||||
client: SchulcloudClient,
|
||||
schoolId: string,
|
||||
parentType: 'lessons' | 'tasks',
|
||||
parentType: FileParentType,
|
||||
parentId: string,
|
||||
): Promise<FileRecord[]> {
|
||||
const page = await client
|
||||
|
||||
@@ -98,3 +98,57 @@ export function padIdFromUrl(url: string, baseUrl: string): string | undefined {
|
||||
const segment = /\/etherpad\/p\/([^/?#]+)/.exec(parsed.pathname)?.[1];
|
||||
return segment && segment.length > 0 ? segment : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of a pad linked from a *topic* ("Thema"), as opposed to a board.
|
||||
*
|
||||
* Topics reach Etherpad differently from column boards: there is no
|
||||
* `collaborative-text-editor` element to ask, only a stored pad url on the
|
||||
* lesson component. The session cookie comes from the topic page instead —
|
||||
* the legacy client requests an Etherpad session on every topic page whose
|
||||
* lesson has contents, whether or not a pad is present, so simply rendering
|
||||
* the page yields one. Everything here is a GET.
|
||||
*
|
||||
* The stored url is data and may point anywhere; `padIdFromUrl` refuses any
|
||||
* host but this instance's, so a pad recorded against another deployment is
|
||||
* reported as a link rather than fetched — which is the correct outcome, not
|
||||
* a failure.
|
||||
*/
|
||||
export async function fetchLessonPadText(
|
||||
config: Config,
|
||||
courseId: string,
|
||||
lessonId: string,
|
||||
padUrl: string,
|
||||
): Promise<string | undefined> {
|
||||
const padId = padIdFromUrl(padUrl, config.baseUrl);
|
||||
if (!padId) return undefined;
|
||||
|
||||
try {
|
||||
const page = await fetch(
|
||||
`${config.baseUrl}/courses/${encodeURIComponent(courseId)}/topics/${encodeURIComponent(lessonId)}`,
|
||||
{
|
||||
headers: { Cookie: `jwt=${config.jwt}`, Accept: 'text/html' },
|
||||
signal: AbortSignal.timeout(config.requestTimeoutMs),
|
||||
redirect: 'follow',
|
||||
},
|
||||
);
|
||||
if (!page.ok) return undefined;
|
||||
|
||||
const sessionCookie = page.headers
|
||||
.getSetCookie()
|
||||
.map((cookie) => /^(sessionID=[^;]*)/.exec(cookie)?.[1])
|
||||
.find((value): value is string => Boolean(value));
|
||||
if (!sessionCookie) return undefined;
|
||||
|
||||
const response = await fetch(`${new URL(config.baseUrl).origin}/etherpad/p/${padId}/export/txt`, {
|
||||
headers: { Cookie: sessionCookie, Accept: 'text/plain' },
|
||||
signal: AbortSignal.timeout(config.requestTimeoutMs),
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
|
||||
const text = (await response.text()).trim();
|
||||
return text.length > 0 && text !== DEFAULT_PAD_TEXT ? text : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +38,51 @@ export interface SubmissionDetail {
|
||||
submittedFiles: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One submission as the *teacher's* grading form holds it.
|
||||
*
|
||||
* The teacher view of `/homework/{id}` is a different page from the student's:
|
||||
* its tabs are `extended` and `submissions` rather than `submission` and
|
||||
* `feedback`, and the grade lives in the editable form rather than in rendered
|
||||
* prose. The student parser therefore finds nothing on it, which is why a
|
||||
* teacher account reported every graded submission as "neither a percentage nor
|
||||
* feedback was found" while the data was plainly there.
|
||||
*/
|
||||
export interface SubmissionGrading {
|
||||
submissionId: string;
|
||||
/** Ids from the form's `teamMembers` field — who handed this in. */
|
||||
submitterIds: string[];
|
||||
gradeComment?: string;
|
||||
gradePercent?: number;
|
||||
}
|
||||
|
||||
/** Everything one homework page yields, for whichever role is looking at it. */
|
||||
export interface HomeworkPage {
|
||||
/** The account's own submission, when the page is the student view. */
|
||||
own?: SubmissionDetail;
|
||||
/** Every submission on the grading form, when the page is the teacher view. */
|
||||
grading: SubmissionGrading[];
|
||||
}
|
||||
|
||||
export async function fetchHomeworkPage(config: Config, taskId: string): Promise<HomeworkPage | undefined> {
|
||||
const html = await fetchHomeworkHtml(config, taskId);
|
||||
if (html === undefined) return undefined;
|
||||
const own = parseHomeworkPage(html);
|
||||
const grading = parseTeacherGrading(html);
|
||||
if (!own && grading.length === 0) return undefined;
|
||||
return { own, grading };
|
||||
}
|
||||
|
||||
export async function fetchSubmissionDetail(
|
||||
config: Config,
|
||||
taskId: string,
|
||||
): Promise<SubmissionDetail | undefined> {
|
||||
const html = await fetchHomeworkHtml(config, taskId);
|
||||
return html === undefined ? undefined : parseHomeworkPage(html);
|
||||
}
|
||||
|
||||
async function fetchHomeworkHtml(config: Config, taskId: string): Promise<string | undefined> {
|
||||
const url = `${config.baseUrl}/homework/${encodeURIComponent(taskId)}`;
|
||||
let html: string;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { Cookie: `jwt=${config.jwt}`, Accept: 'text/html' },
|
||||
@@ -55,12 +94,51 @@ export async function fetchSubmissionDetail(
|
||||
if (!response.ok || !new URL(response.url).hostname.endsWith(new URL(config.baseUrl).hostname)) {
|
||||
return undefined;
|
||||
}
|
||||
html = await response.text();
|
||||
return await response.text();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return parseHomeworkPage(html);
|
||||
/**
|
||||
* Exported for testing: reads the teacher's grading form.
|
||||
*
|
||||
* Anchored on the form's own `name=` attributes rather than on layout, because
|
||||
* those are what the POST handler reads and so cannot drift without the feature
|
||||
* itself changing. Each submission contributes one `submissionId` hidden input,
|
||||
* a `teamMembers` input naming who handed it in, a `grade` number input, and a
|
||||
* `gradeComment` textarea whose body is HTML-escaped twice over.
|
||||
*/
|
||||
export function parseTeacherGrading(html: string): SubmissionGrading[] {
|
||||
const found: SubmissionGrading[] = [];
|
||||
const blocks = html.split(/<input name="submissionId"/);
|
||||
for (const block of blocks.slice(1)) {
|
||||
const submissionId = /value="([0-9a-f]{24})"/.exec(block)?.[1];
|
||||
if (!submissionId) continue;
|
||||
|
||||
// Only trust fields belonging to this submission: the next block starts
|
||||
// at the following submissionId input, so cut there first.
|
||||
const members = /<input name="teamMembers"[^>]*value="([^"]*)"/.exec(block)?.[1] ?? '';
|
||||
const submitterIds = members
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => /^[0-9a-f]{24}$/.test(id));
|
||||
|
||||
const entry: SubmissionGrading = { submissionId, submitterIds };
|
||||
|
||||
// `value=""` means ungraded; the placeholder is a hint, not a grade.
|
||||
const gradeValue = /name="grade"[^>]*?value="(\d{1,3})"/.exec(block)?.[1];
|
||||
if (gradeValue !== undefined) entry.gradePercent = Number(gradeValue);
|
||||
|
||||
const commentMarkup = new RegExp(
|
||||
`<textarea[^>]*data-parent-id="${submissionId}"[^>]*>([\\s\\S]*?)</textarea>`,
|
||||
).exec(html)?.[1];
|
||||
const comment = clean(commentMarkup ? decodeEntities(commentMarkup) : undefined);
|
||||
if (comment) entry.gradeComment = comment;
|
||||
|
||||
found.push(entry);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Exported for testing: the parsing is pure and deserves fixtures, not a network. */
|
||||
|
||||
@@ -57,8 +57,24 @@ export function htmlToText(html: string | undefined | null): string {
|
||||
if (!html) return '';
|
||||
return decodeEntities(
|
||||
html
|
||||
// A newline in HTML source is just whitespace; only tags make lines.
|
||||
// Flattening first is what stops `<br>` followed by a newline — the
|
||||
// shape every server-side template produces — from reading as a
|
||||
// paragraph break, and it takes the template's indentation with it.
|
||||
.replace(/\s*\n\s*/g, ' ')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|h[1-6]|li|tr)>/gi, '\n')
|
||||
// A cell whose text is wrapped in its own <p> — which is what the
|
||||
// editor produces — would otherwise break its row in half.
|
||||
.replace(/<(td|th)([^>]*)>\s*<p[^>]*>/gi, '<$1$2>')
|
||||
.replace(/<\/p>\s*<\/(td|th)>/gi, '</$1>')
|
||||
// Cells before rows: a table flattened without cell separators runs
|
||||
// its columns together, which is how a two-column worksheet grid came
|
||||
// out as a meaningless list of fragments.
|
||||
.replace(/<\/(td|th)>/gi, ' | ')
|
||||
// Paragraphs and headings read as paragraphs; list items and table
|
||||
// rows are single lines.
|
||||
.replace(/<\/(p|h[1-6])>/gi, '\n\n')
|
||||
.replace(/<\/(div|li|tr)>/gi, '\n')
|
||||
.replace(/<li[^>]*>/gi, '- ')
|
||||
// Keep the href when the anchor text does not already contain it.
|
||||
.replace(/<a\b[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, (_, href: string, label: string) => {
|
||||
@@ -68,7 +84,14 @@ export function htmlToText(html: string | undefined | null): string {
|
||||
})
|
||||
.replace(/<[^>]+>/g, ''),
|
||||
)
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
// Source HTML is pretty-printed, so nearly every line arrives with the
|
||||
// template's indentation still attached. Collapsing runs of spaces and
|
||||
// trimming each line is what HTML rendering would have done anyway, and
|
||||
// without it a submission reads as prose adrift in whitespace.
|
||||
.replace(/[^\S\n]+/g, ' ')
|
||||
.split('\n')
|
||||
.map((line) => line.trim().replace(/\s*\|\s*$/, ''))
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
@@ -257,8 +257,14 @@ export interface RoomItem {
|
||||
endDate?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
/** What this account may do here — 'room_edit_content' and friends. */
|
||||
allowedOperations?: string[];
|
||||
/**
|
||||
* What this account may do here.
|
||||
*
|
||||
* An object keyed by operation, not a list of granted ones: every operation
|
||||
* is present and false means denied. Typing it as `string[]` type-checked
|
||||
* fine and threw `.some is not a function` the moment anything read it.
|
||||
*/
|
||||
allowedOperations?: Record<string, boolean>;
|
||||
isLocked?: boolean;
|
||||
totalMembers?: number;
|
||||
}
|
||||
@@ -294,6 +300,25 @@ export interface RoomMember {
|
||||
schoolName?: string;
|
||||
}
|
||||
|
||||
/** Someone who has asked to join a room and is waiting for an admin. */
|
||||
export interface RoomApplicant {
|
||||
userId?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
schoolName?: string;
|
||||
requestedAt?: string;
|
||||
}
|
||||
|
||||
/** A shareable link into a room. Visible only to accounts that may manage them. */
|
||||
export interface RoomInvitationLink {
|
||||
id: string;
|
||||
title?: string;
|
||||
activeUntil?: string;
|
||||
isOnlyForTeachers?: boolean;
|
||||
restrictedToCreatorSchool?: boolean;
|
||||
requiresConfirmation?: boolean;
|
||||
}
|
||||
|
||||
export const FILE_PARENT_TYPES: FileParentType[] = [
|
||||
'users',
|
||||
'schools',
|
||||
@@ -349,3 +374,84 @@ export interface NewsResponse {
|
||||
creator?: { id: string; firstName?: string; lastName?: string };
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A course as the legacy `/api/v1/courses` service returns it.
|
||||
*
|
||||
* The v3 projection (`CourseMetadataResponse`) carries only id, title, colour
|
||||
* and dates — no description, no teachers, no members, no timetable. All of
|
||||
* that still exists, and `/api/v1/courses` is one of exactly three legacy
|
||||
* routes the deployment's own ingress table still publishes
|
||||
* (`dof_app_deploy/ansible/group_vars/all/x_ingress.yml`: courses, users,
|
||||
* classes), so this is production surface rather than a leftover.
|
||||
*/
|
||||
export interface LegacyCourse {
|
||||
_id?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
startDate?: string;
|
||||
untilDate?: string;
|
||||
isArchived?: boolean;
|
||||
teacherIds?: string[];
|
||||
substitutionIds?: string[];
|
||||
userIds?: string[];
|
||||
classIds?: string[];
|
||||
/** The weekly timetable: one entry per recurring slot. */
|
||||
times?: CourseTime[];
|
||||
}
|
||||
|
||||
/** One recurring slot of a course's weekly timetable. */
|
||||
export interface CourseTime {
|
||||
/** 0 = Monday, as the legacy client renders it. */
|
||||
weekday?: number;
|
||||
/** Milliseconds since midnight. */
|
||||
startTime?: number;
|
||||
/** Milliseconds. */
|
||||
duration?: number;
|
||||
room?: string;
|
||||
}
|
||||
|
||||
/** A user as `/api/v1/users/{id}` returns it — the only way to turn an id into a name. */
|
||||
export interface LegacyUser {
|
||||
_id?: string;
|
||||
id?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
fullName?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
/** A class ("Klasse") from `/api/v3/groups/class`. */
|
||||
export interface ClassItem {
|
||||
id: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
teacherNames?: string[];
|
||||
studentCount?: number;
|
||||
isUpgradable?: boolean;
|
||||
}
|
||||
|
||||
/** A group from `/api/v3/groups` — room membership groups, classes, courses. */
|
||||
export interface GroupItem {
|
||||
id: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
organizationId?: string;
|
||||
users?: { id: string; firstName?: string; lastName?: string; role?: string }[];
|
||||
}
|
||||
|
||||
/** `GET /api/v3/file/stats/{parentType}/{parentId}`. */
|
||||
export interface ParentFileStats {
|
||||
fileCount: number;
|
||||
totalSizeInBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Widths the preview endpoint accepts.
|
||||
*
|
||||
* An enum rather than a free number — `width=1600` is rejected as a validation
|
||||
* error that names the value but not the permitted set.
|
||||
*/
|
||||
export type PreviewWidth = 50 | 150 | 500;
|
||||
|
||||
Reference in New Issue
Block a user