Files
Schulcloud-MCP/src/core/untis-history.ts
MechaCat02 af4464decb Read the user's own lesson notes, and the class register behind them
Schulcloud says what was uploaded and WebUntis says what was scheduled.
Neither says what was *taught* — which point the teacher laboured, which
example landed, what "will definitely come up". That lives in two places
this server could not reach: the notes the user takes in the lesson, and
WebUntis' class register.

Notes are a directory of Markdown files (NOTES_DIR), not a table. They
have to be writable from a phone in a classroom, readable when Postgres
is down, and outlive this project, and files are the only shape that is
all three — so the files are the truth and the index is a view of them,
the same split as file_texts and the mirror. list_notes and get_note read
disk, so they answer before the first crawl; search, what_changed and all
three German prompts read them alongside the Schulcloud material.

add_note writes one, and is the only thing in this server that writes
anything. That is not a hole in the read-only invariant but a different
store: it is bounded to NOTES_DIR by the same safeComponent/resolveWithin
pair that stops a hostile Schulcloud filename escaping the mirror, so a
note titled ../../.ssh/authorized_keys becomes a filename. Schulcloud and
WebUntis stay GET-only and allowlisted respectively. NOTES_READONLY
refuses writes outright.

Appending targets the *lesson*, not the title: "halt das auch noch fest"
mid-lesson carries a new title, and deriving the path from it would start
a second note every time, which is the one thing append exists to prevent.

Notes.app has no export — its bodies are compressed protobuf and the
iCloud copy is encrypted — so scripting the app is not the clumsy route
to the notes but the only one. scripts/export-apple-notes.js reads them
through AppleScript into one JSON object per line, and `schulcloud note
import` converts the HTML to Markdown, takes the Notes folder as the
subject and the *creation* date as the lesson's date. Attachments cannot
come across; a note that was a photo of the board imports as a line
saying so, because importing it empty would hide the loss.

The class register needed one API property to become cheap:
getLessonTopic2017 answers per *series*, not per period, so a term is
reconstructed by asking about the latest period of each lesson series and
merging back by id — a few dozen calls for a school year rather than one
per lesson. untis_lesson_topics now takes a subject as well as a period
id, and UNTIS_HISTORY_DAYS of register goes into the index under a kind
of its own, so "what did we actually do before the test" is searchable.

Sharing the snapshot rather than duplicating it caught one thing on the
way: the search tool's live path had to learn notes too, or fresh=true
would have quietly disagreed with the index.

305 tests; 88/89 smoke against the local instance, the one failure being
the H5P service that instance does not run. The live smoke could not be
retaken: that session has lapsed and needs a fresh jwt cookie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:46:26 +02:00

179 lines
6.7 KiB
TypeScript

import { addDays, daysBetween } from './dates.ts';
import type { UntisClient, UntisLesson } from './untis.ts';
/**
* The class register, read backwards: what every past lesson actually covered.
*
* `untis_lesson_topics` answers this one series at a time, from a period id the
* caller already has. That is the wrong shape for two of the questions this
* exists for — "what have we done in Deutsch this term" and "where does the
* material about X come from" — because neither starts from a period id, and
* the second needs the text in the search index rather than in a tool call.
*
* So this walks a date range and merges the two halves the API keeps apart:
*
* - `getTimetable2017` gives the periods, and with them what a teacher wrote on
* one (`text.info` is where this school announces its tests), the homework and
* any exam.
* - `getLessonTopic2017` gives the `Unterrichtsinhalt` — but only per *series*,
* as "the previous topics of this lesson". One call per series therefore
* covers all of its past lessons at once, which is why this asks per series
* and not per period: a term is a few dozen calls, not a few hundred.
*
* Requests are sequential on purpose. Every WebUntis call carries its own
* one-time code, and the index is built by a background crawl that nobody is
* waiting on, so there is nothing to buy by running them in parallel.
*/
/** The longest range one `getTimetable2017` call is asked for. */
const CHUNK_DAYS = 90;
export interface LessonLogEntry {
periodId: number;
/** The series id: every Tuesday-second-period German lesson shares it. */
lessonId: number;
date: string;
start: string;
end: string;
subject?: string;
subjectLong?: string;
teachers: string[];
/** The class register's "Unterrichtsinhalt" for this period, when the teacher filled it in. */
topic?: string;
/** The free-text fields on the period. `info` is where announced tests live. */
notes: { lesson?: string; substitution?: string; info?: string };
homework: { text: string; due: string }[];
exam?: string;
}
export interface LessonLog {
from: string;
to: string;
entries: LessonLogEntry[];
/** Series whose topics could not be read, so a gap is visible rather than silent. */
failures: { lessonId: number; reason: string }[];
/** Periods seen in the range, including the ones that carried nothing. */
periodsSeen: number;
}
/**
* Collects the log for `[from, to]`.
*
* Only periods that carry something are returned: a lesson with neither a
* topic, nor a note, nor homework, nor an exam has nothing to say, and
* indexing it would bury the ones that do under a term of empty rows.
*/
export async function collectLessonLog(
untis: UntisClient,
options: { from: string; to: string; subject?: string },
): Promise<LessonLog> {
const lessons: UntisLesson[] = [];
for (const [chunkFrom, chunkTo] of chunkRange(options.from, options.to)) {
const table = await untis.timetable(chunkFrom, chunkTo);
for (const day of table.days) lessons.push(...day.lessons);
}
// A cancelled period taught nothing, and its replacement beside it carries
// whatever actually happened.
const held = lessons.filter((lesson) => !lesson.cancelled).filter((lesson) => matchesSubject(lesson, options.subject));
const topics = new Map<number, string>();
const failures: { lessonId: number; reason: string }[] = [];
for (const [lessonId, periodId] of latestPeriodPerSeries(held)) {
try {
for (const topic of await untis.lessonTopics(periodId)) topics.set(topic.periodId, topic.text);
} catch (error) {
// One series the register refuses must not cost the rest of the term.
failures.push({ lessonId, reason: error instanceof Error ? error.message : String(error) });
}
}
const entries = held
.map((lesson): LessonLogEntry => {
const subject = lesson.subjects[0];
return {
periodId: lesson.periodId,
lessonId: lesson.lessonId,
date: lesson.date,
start: lesson.start,
end: lesson.end,
...(subject?.name ? { subject: subject.name } : {}),
...(subject?.longName ? { subjectLong: subject.longName } : {}),
teachers: lesson.teachers.map((teacher) => teacher.longName || teacher.name),
...(topics.get(lesson.periodId) ? { topic: topics.get(lesson.periodId) } : {}),
notes: lesson.notes,
homework: lesson.homework.map((item) => ({ text: item.text, due: item.due })),
...(lesson.exam ? { exam: lesson.exam } : {}),
};
})
.filter(hasContent)
.sort((a, b) => b.date.localeCompare(a.date) || b.start.localeCompare(a.start));
return { from: options.from, to: options.to, entries, failures, periodsSeen: held.length };
}
/** True when the entry records anything worth keeping. */
export function hasContent(entry: LessonLogEntry): boolean {
return Boolean(
entry.topic ||
entry.notes.lesson ||
entry.notes.info ||
entry.notes.substitution ||
entry.exam ||
entry.homework.length > 0,
);
}
/** Everything the entry says, as one string — the body the index gets. */
export function lessonLogText(entry: LessonLogEntry): string {
return [
entry.topic,
entry.notes.info,
entry.notes.lesson,
entry.notes.substitution,
entry.exam ? `Prüfung: ${entry.exam}` : undefined,
...entry.homework.map((item) => `Hausaufgabe bis ${item.due}: ${item.text}`),
]
.filter(Boolean)
.join('\n');
}
/** `LF07` matches the subject `LF07` and the long name `Lernfeld 7`. */
function matchesSubject(lesson: UntisLesson, subject: string | undefined): boolean {
if (!subject) return true;
const wanted = subject.trim().toLowerCase();
return lesson.subjects.some(
(entry) =>
entry.name.toLowerCase().includes(wanted) || (entry.longName ?? '').toLowerCase().includes(wanted),
);
}
/**
* One period id per series — the latest one.
*
* `getLessonTopic2017` answers with the topics of the lessons *before* the
* period it is given, so asking about the last lesson of a series reaches the
* whole of its history and asking about the first reaches none of it.
*/
function latestPeriodPerSeries(lessons: UntisLesson[]): Map<number, number> {
const latest = new Map<number, { periodId: number; at: string }>();
for (const lesson of lessons) {
const at = `${lesson.date} ${lesson.start}`;
const current = latest.get(lesson.lessonId);
if (!current || at > current.at) latest.set(lesson.lessonId, { periodId: lesson.periodId, at });
}
return new Map([...latest].map(([lessonId, entry]) => [lessonId, entry.periodId]));
}
/** Splits a range into windows the timetable call will accept. */
export function chunkRange(from: string, to: string): [string, string][] {
const chunks: [string, string][] = [];
let start = from;
while (start <= to) {
const end = daysBetween(start, to) > CHUNK_DAYS ? addDays(start, CHUNK_DAYS) : to;
chunks.push([start, end]);
start = addDays(end, 1);
}
return chunks;
}