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>
179 lines
6.7 KiB
TypeScript
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;
|
|
}
|