You were right that this data never reaches the browser as an API call.
The legacy front end calls the Feathers API server-side for
submission.comment, submission.grade and submission.gradeComment and
renders them into GET /homework/{taskId}. That Feathers API is not
exposed publicly — /api/v1/* 404s — so the rendered page is the only way
to reach these fields from outside.
core/homework-page.ts parses it, hooked on the data-testid attributes
the project's own e2e tests use rather than incidental markup. The page
authenticates by jwt *cookie*; an Authorization header is ignored and
redirects to the identity provider. Every field is optional and parse
failures return undefined, so a markup change degrades to "not found"
and cannot break get_task. The wording distinguishes the two: absent
feedback is reported as not found, never as none given.
Measured on one course: 4 of 7 graded submissions carry feedback no API
call can return — "vollständig und nachvollziehbar", "Feedback siehe
Zettel", and so on.
This exposed a bug in a shared utility: htmlToText decoded only six
entities, so any named entity passed through raw. German content makes
that routine — "vollständig" would have reached the model verbatim
from boards and task descriptions too, not just here. It now decodes
named, decimal and hex references in one pass, so ä stays
literal instead of decoding twice, and leaves unknown names alone rather
than mangling them.
Also fixes a documented-recovery bug found while restoring the session:
`docker compose restart` does not re-read env_file, so it silently kept
serving the dead token. `up -d` is correct and the docs said the wrong
thing.
78 tests, 38/38 smoke; verified end to end through Claude Code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
163 lines
6.2 KiB
TypeScript
163 lines
6.2 KiB
TypeScript
/**
|
||
* Formatting helpers shared by the tools.
|
||
*
|
||
* Tool results are read by a model, so everything renders to compact Markdown
|
||
* rather than raw JSON: ids stay visible (Claude needs them for follow-up
|
||
* calls) but the surrounding noise — display colours, positions, buffer-shaped
|
||
* Mongo ids — is dropped.
|
||
*/
|
||
|
||
/**
|
||
* Named HTML entities worth decoding.
|
||
*
|
||
* Not the full HTML5 table — that is ~2000 entries for a handful this content
|
||
* actually uses. German umlauts are the ones that matter: the legacy web pages
|
||
* emit `ä` rather than UTF-8, so without these a grade comment comes back
|
||
* as "vollständig".
|
||
*/
|
||
const NAMED_ENTITIES: Record<string, string> = {
|
||
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ',
|
||
auml: 'ä', ouml: 'ö', uuml: 'ü', Auml: 'Ä', Ouml: 'Ö', Uuml: 'Ü', szlig: 'ß',
|
||
aacute: 'á', agrave: 'à', acirc: 'â', eacute: 'é', egrave: 'è', ecirc: 'ê',
|
||
iacute: 'í', igrave: 'ì', oacute: 'ó', ograve: 'ò', ocirc: 'ô',
|
||
uacute: 'ú', ugrave: 'ù', ccedil: 'ç', ntilde: 'ñ',
|
||
euro: '€', pound: '£', deg: '°', middot: '·', bull: '•', hellip: '…',
|
||
ndash: '–', mdash: '—', laquo: '«', raquo: '»',
|
||
lsquo: '\u2018', rsquo: '\u2019', ldquo: '\u201c', rdquo: '\u201d', bdquo: '\u201e',
|
||
times: '×', divide: '÷', plusmn: '±', copy: '©', reg: '®', trade: '™', shy: '',
|
||
};
|
||
|
||
/**
|
||
* Decodes HTML entities: named, decimal and hexadecimal.
|
||
*
|
||
* `&` is handled by the same pass as everything else rather than last,
|
||
* so `&auml;` stays the literal text `ä` instead of being decoded
|
||
* twice into `ä`.
|
||
*/
|
||
export function decodeEntities(value: string): string {
|
||
return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]{1,31});/g, (whole, body: string) => {
|
||
if (body.startsWith('#')) {
|
||
const code = body[1] === 'x' || body[1] === 'X'
|
||
? Number.parseInt(body.slice(2), 16)
|
||
: Number.parseInt(body.slice(1), 10);
|
||
if (!Number.isFinite(code) || code <= 0 || code > 0x10ffff) return whole;
|
||
try {
|
||
return String.fromCodePoint(code);
|
||
} catch {
|
||
return whole;
|
||
}
|
||
}
|
||
// Unknown names are left alone: mangling them loses information.
|
||
return NAMED_ENTITIES[body] ?? whole;
|
||
});
|
||
}
|
||
|
||
/** Collapses Schulcloud's CKEditor HTML into plain text, keeping link targets. */
|
||
export function htmlToText(html: string | undefined | null): string {
|
||
if (!html) return '';
|
||
return decodeEntities(
|
||
html
|
||
.replace(/<br\s*\/?>/gi, '\n')
|
||
.replace(/<\/(p|div|h[1-6]|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) => {
|
||
const text = label.replace(/<[^>]+>/g, '').trim();
|
||
if (!text) return href;
|
||
return text === href ? href : `${text} (${href})`;
|
||
})
|
||
.replace(/<[^>]+>/g, ''),
|
||
)
|
||
.replace(/[ \t]+\n/g, '\n')
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim();
|
||
}
|
||
|
||
/** `2026-08-17T08:00:00.000Z` → `2026-08-17 08:00`; passes other values through. */
|
||
export function formatDate(value: string | null | undefined): string {
|
||
if (!value) return '—';
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return value;
|
||
return date.toISOString().replace('T', ' ').slice(0, 16);
|
||
}
|
||
|
||
/** Days from now until `value`; negative when overdue. `undefined` if unset. */
|
||
export function daysUntil(value: string | null | undefined): number | undefined {
|
||
if (!value) return undefined;
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return undefined;
|
||
return Math.round((date.getTime() - Date.now()) / 86_400_000);
|
||
}
|
||
|
||
export function dueLabel(dueDate: string | null | undefined): string {
|
||
const days = daysUntil(dueDate);
|
||
if (days === undefined) return 'no due date';
|
||
if (days < 0) return `due ${formatDate(dueDate)} (${Math.abs(days)}d overdue)`;
|
||
if (days === 0) return `due ${formatDate(dueDate)} (today)`;
|
||
return `due ${formatDate(dueDate)} (in ${days}d)`;
|
||
}
|
||
|
||
export function heading(level: number, text: string): string {
|
||
return `${'#'.repeat(level)} ${text}`;
|
||
}
|
||
|
||
/** Joins sections, dropping empties, with exactly one blank line between them. */
|
||
export function joinSections(parts: (string | undefined | null | false)[]): string {
|
||
return parts.filter((part): part is string => Boolean(part && part.trim())).join('\n\n');
|
||
}
|
||
|
||
/**
|
||
* Mongo ObjectIds sometimes come back from the legacy lesson API serialised as
|
||
* `{ buffer: { type: 'Buffer', data: [...] } }` instead of a hex string.
|
||
*/
|
||
export function normalizeObjectId(value: unknown): string | undefined {
|
||
if (typeof value === 'string') return value;
|
||
if (value && typeof value === 'object') {
|
||
const data = (value as { buffer?: { data?: unknown } }).buffer?.data;
|
||
if (Array.isArray(data)) {
|
||
return data.map((byte) => Number(byte).toString(16).padStart(2, '0')).join('');
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
// --- search text utilities ---------------------------------------------
|
||
|
||
/**
|
||
* Lowercases and strips diacritics, so a query typed without umlauts still
|
||
* matches. German content makes this non-optional: "Verschlusselung" has to
|
||
* find "Verschlüsselung", and ß has to fold to ss.
|
||
*/
|
||
export function fold(value: string): string {
|
||
return value
|
||
.normalize('NFD')
|
||
.replace(/[̀-ͯ]/g, '')
|
||
.replace(/ß/g, 'ss')
|
||
.toLowerCase();
|
||
}
|
||
|
||
/** Splits a query into searchable terms, dropping punctuation and stray letters. */
|
||
export function tokenize(query: string): string[] {
|
||
return fold(query)
|
||
.split(/[^\p{L}\p{N}]+/u)
|
||
.filter((token) => token.length >= 2);
|
||
}
|
||
|
||
/** True when every term appears somewhere in `haystack`. */
|
||
export function matchesAll(haystack: string | undefined, terms: string[]): boolean {
|
||
if (!haystack) return false;
|
||
const folded = fold(haystack);
|
||
return terms.every((term) => folded.includes(term));
|
||
}
|
||
|
||
/** A one-line excerpt centred on the first matching term. */
|
||
export function snippet(haystack: string, terms: string[], width = 180): string {
|
||
const flat = haystack.replace(/\s+/g, ' ').trim();
|
||
const folded = fold(flat);
|
||
const at = terms.map((term) => folded.indexOf(term)).filter((index) => index >= 0);
|
||
const centre = at.length > 0 ? Math.min(...at) : 0;
|
||
const start = Math.max(0, Math.floor(centre - width / 3));
|
||
const excerpt = flat.slice(start, start + width);
|
||
return `${start > 0 ? '…' : ''}${excerpt}${start + width < flat.length ? '…' : ''}`;
|
||
}
|