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>
This commit is contained in:
160
test/untis-history.test.ts
Normal file
160
test/untis-history.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { chunkRange, collectLessonLog, hasContent, lessonLogText } from '../src/core/untis-history.ts';
|
||||
import type { UntisClient, UntisLesson, UntisTimetable, UntisTopic } from '../src/core/untis.ts';
|
||||
|
||||
/**
|
||||
* The class register, read backwards. The client is a stand-in: what is under
|
||||
* test is which periods are asked about and how the two halves are merged, not
|
||||
* the JSON-RPC layer, which test/untis.test.ts already covers.
|
||||
*/
|
||||
|
||||
function lesson(overrides: Partial<UntisLesson> & { periodId: number; lessonId: number; date: string }): UntisLesson {
|
||||
return {
|
||||
start: '08:00',
|
||||
end: '08:45',
|
||||
statuses: ['REGULAR'],
|
||||
cancelled: false,
|
||||
changed: false,
|
||||
subjects: [{ name: 'DE', longName: 'Deutsch' }],
|
||||
teachers: [{ name: 'MEI', longName: 'Meier' }],
|
||||
rooms: [],
|
||||
classes: [],
|
||||
replaced: { subjects: [], teachers: [], rooms: [] },
|
||||
notes: {},
|
||||
homework: [],
|
||||
online: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function client(lessons: UntisLesson[], topics: Record<number, UntisTopic[]>, seen?: { periods: number[] }): UntisClient {
|
||||
return {
|
||||
async timetable(from: string, to: string): Promise<UntisTimetable> {
|
||||
const days = [...new Set(lessons.map((entry) => entry.date))].filter((date) => date >= from && date <= to);
|
||||
return { from, to, days: days.map((date) => ({ date, lessons: lessons.filter((l) => l.date === date), holidays: [] })) };
|
||||
},
|
||||
async lessonTopics(periodId: number): Promise<UntisTopic[]> {
|
||||
seen?.periods.push(periodId);
|
||||
const found = topics[periodId];
|
||||
if (!found) throw new Error(`period ${periodId} not found`);
|
||||
return found;
|
||||
},
|
||||
} as unknown as UntisClient;
|
||||
}
|
||||
|
||||
describe('chunkRange', () => {
|
||||
it('is one window for a short range', () => {
|
||||
assert.deepEqual(chunkRange('2026-09-01', '2026-09-30'), [['2026-09-01', '2026-09-30']]);
|
||||
});
|
||||
|
||||
it('splits a school year into windows the timetable call accepts', () => {
|
||||
const chunks = chunkRange('2026-01-01', '2026-12-31');
|
||||
assert.ok(chunks.length > 1);
|
||||
assert.equal(chunks[0]![0], '2026-01-01');
|
||||
assert.equal(chunks.at(-1)![1], '2026-12-31');
|
||||
// No gaps and no overlaps: every day belongs to exactly one window.
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
const previousEnd = new Date(`${chunks[i - 1]![1]}T12:00:00Z`).getTime();
|
||||
const start = new Date(`${chunks[i]![0]}T12:00:00Z`).getTime();
|
||||
assert.equal(start - previousEnd, 86_400_000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectLessonLog', () => {
|
||||
it('asks each series once, about its latest period', async () => {
|
||||
// getLessonTopic2017 answers with the lessons *before* the period given,
|
||||
// so the newest period of a series reaches all of its history and one
|
||||
// call per series covers a term.
|
||||
const seen = { periods: [] as number[] };
|
||||
const lessons = [
|
||||
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01' }),
|
||||
lesson({ periodId: 2, lessonId: 100, date: '2026-09-08' }),
|
||||
lesson({ periodId: 3, lessonId: 200, date: '2026-09-09' }),
|
||||
];
|
||||
const topics = {
|
||||
2: [{ text: 'Erörterung', periodId: 1, date: '2026-09-01', start: '08:00', end: '08:45' }],
|
||||
3: [{ text: 'Netze', periodId: 3, date: '2026-09-09', start: '08:00', end: '08:45' }],
|
||||
};
|
||||
await collectLessonLog(client(lessons, topics, seen), { from: '2026-09-01', to: '2026-09-30' });
|
||||
assert.deepEqual(seen.periods.sort(), [2, 3]);
|
||||
});
|
||||
|
||||
it('merges a topic onto the period it belongs to', async () => {
|
||||
const lessons = [lesson({ periodId: 1, lessonId: 100, date: '2026-09-01' }), lesson({ periodId: 2, lessonId: 100, date: '2026-09-08' })];
|
||||
const topics = { 2: [{ text: 'Erörterung', periodId: 1, date: '2026-09-01', start: '08:00', end: '08:45' }] };
|
||||
const log = await collectLessonLog(client(lessons, topics), { from: '2026-09-01', to: '2026-09-30' });
|
||||
assert.deepEqual(log.entries.map((entry) => [entry.periodId, entry.topic]), [[1, 'Erörterung']]);
|
||||
});
|
||||
|
||||
it('keeps a lesson that has only a teacher note, and drops the empty ones', async () => {
|
||||
const lessons = [
|
||||
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', notes: { info: 'LK am 20.09.' } }),
|
||||
lesson({ periodId: 2, lessonId: 100, date: '2026-09-08' }),
|
||||
];
|
||||
const log = await collectLessonLog(client(lessons, { 2: [] }), { from: '2026-09-01', to: '2026-09-30' });
|
||||
assert.deepEqual(log.entries.map((entry) => entry.periodId), [1]);
|
||||
assert.equal(log.periodsSeen, 2);
|
||||
});
|
||||
|
||||
it('skips cancelled periods, which taught nothing', async () => {
|
||||
const lessons = [lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', cancelled: true, notes: { info: 'Entfall' } })];
|
||||
const log = await collectLessonLog(client(lessons, {}), { from: '2026-09-01', to: '2026-09-30' });
|
||||
assert.equal(log.periodsSeen, 0);
|
||||
assert.deepEqual(log.entries, []);
|
||||
});
|
||||
|
||||
it('matches a subject on either its code or its long name', async () => {
|
||||
const lessons = [
|
||||
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', notes: { info: 'x' } }),
|
||||
lesson({ periodId: 2, lessonId: 200, date: '2026-09-01', subjects: [{ name: 'LF07', longName: 'Lernfeld 7' }], notes: { info: 'y' } }),
|
||||
];
|
||||
const byCode = await collectLessonLog(client(lessons, {}), { from: '2026-09-01', to: '2026-09-30', subject: 'lf07' });
|
||||
assert.deepEqual(byCode.entries.map((entry) => entry.periodId), [2]);
|
||||
const byName = await collectLessonLog(client(lessons, {}), { from: '2026-09-01', to: '2026-09-30', subject: 'deutsch' });
|
||||
assert.deepEqual(byName.entries.map((entry) => entry.periodId), [1]);
|
||||
});
|
||||
|
||||
it('records a refused series rather than losing the whole term', async () => {
|
||||
const lessons = [
|
||||
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', notes: { info: 'bleibt' } }),
|
||||
lesson({ periodId: 2, lessonId: 200, date: '2026-09-02', notes: { info: 'auch' } }),
|
||||
];
|
||||
// Period 2's series throws; period 1's does not.
|
||||
const log = await collectLessonLog(client(lessons, { 1: [] }), { from: '2026-09-01', to: '2026-09-30' });
|
||||
assert.equal(log.failures.length, 1);
|
||||
assert.equal(log.entries.length, 2);
|
||||
});
|
||||
|
||||
it('is newest first', async () => {
|
||||
const lessons = [
|
||||
lesson({ periodId: 1, lessonId: 100, date: '2026-09-01', notes: { info: 'a' } }),
|
||||
lesson({ periodId: 2, lessonId: 100, date: '2026-09-08', notes: { info: 'b' } }),
|
||||
];
|
||||
const log = await collectLessonLog(client(lessons, { 2: [] }), { from: '2026-09-01', to: '2026-09-30' });
|
||||
assert.deepEqual(log.entries.map((entry) => entry.date), ['2026-09-08', '2026-09-01']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lessonLogText', () => {
|
||||
it('carries the topic, the announcement and the homework into one body', () => {
|
||||
const body = lessonLogText({
|
||||
periodId: 1, lessonId: 100, date: '2026-09-01', start: '08:00', end: '08:45',
|
||||
teachers: ['Meier'], topic: 'Erörterung', notes: { info: 'LK am 20.09.' },
|
||||
homework: [{ text: 'S. 42', due: '2026-09-08' }],
|
||||
});
|
||||
assert.match(body, /Erörterung/);
|
||||
assert.match(body, /LK am 20\.09\./);
|
||||
assert.match(body, /S\. 42/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasContent', () => {
|
||||
it('is false for a lesson that recorded nothing', () => {
|
||||
assert.equal(
|
||||
hasContent({ periodId: 1, lessonId: 1, date: '2026-09-01', start: '08:00', end: '08:45', teachers: [], notes: {}, homework: [] }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user