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:
@@ -5,7 +5,9 @@ import type { SchulcloudClient } from './client.ts';
|
||||
import { fetchHomeworkPage } from './homework-page.ts';
|
||||
import { FileManager, type DirectoryRef, type FmFile, type WalkEntry } from './legacy-files.ts';
|
||||
import { fetchLessonTaskLinks, withScrapedIds } from './lesson-page.ts';
|
||||
import { readNotes, type NoteDoc } from './notes.ts';
|
||||
import { htmlToText, normalizeObjectId } from './text.ts';
|
||||
import type { LessonLogEntry } from './untis-history.ts';
|
||||
import type { CourseMetadata, FileParentType, FileRecord, TaskContent } from './types.ts';
|
||||
|
||||
/**
|
||||
@@ -137,6 +139,22 @@ export interface Snapshot {
|
||||
files: CrawledFile[];
|
||||
/** Populated only when `includePersonalFiles` is set; see that option. */
|
||||
submissions: CrawledSubmission[];
|
||||
/**
|
||||
* The user's own lesson notes, when `notesDir` was given.
|
||||
*
|
||||
* Not from Schulcloud and not fetched over the network — they are read off
|
||||
* disk. They travel in the snapshot because everything that consumes one
|
||||
* wants them: search should find what the user wrote alongside what the
|
||||
* teacher uploaded, and what_changed should notice a note appearing.
|
||||
*/
|
||||
notes: NoteDoc[];
|
||||
/**
|
||||
* The WebUntis class register for the recent past, when the caller attached
|
||||
* one. The crawl never fills this itself: it is a second upstream with its
|
||||
* own credential and its own request budget, so the indexer collects it and
|
||||
* hangs it here rather than making every live search pay for it.
|
||||
*/
|
||||
lessonLog: LessonLogEntry[];
|
||||
/**
|
||||
* 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
|
||||
@@ -175,6 +193,11 @@ export interface CrawlOptions {
|
||||
* second credentialled hop outside the API. Omit to leave pads unread.
|
||||
*/
|
||||
config?: Config;
|
||||
/**
|
||||
* Read the user's own notes from this directory into the snapshot. Local
|
||||
* disk, so it is cheap enough for the live search path as well as the index.
|
||||
*/
|
||||
notesDir?: string;
|
||||
courseConcurrency?: number;
|
||||
boardConcurrency?: number;
|
||||
onProgress?: (done: number, total: number, label: string) => void;
|
||||
@@ -248,6 +271,11 @@ export async function crawl(client: SchulcloudClient, options: CrawlOptions): Pr
|
||||
files.sort((a, b) => a.record.id.localeCompare(b.record.id));
|
||||
submissions.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
// Local disk, and never fatal: a notes directory that does not exist yet is
|
||||
// an empty one, and a crawl must not fail over the half of the picture that
|
||||
// is not Schulcloud's.
|
||||
const notes = options.notesDir ? await readNotes(options.notesDir).catch(() => []) : [];
|
||||
|
||||
return {
|
||||
crawledAt: new Date(),
|
||||
schoolId: options.schoolId,
|
||||
@@ -255,6 +283,8 @@ export async function crawl(client: SchulcloudClient, options: CrawlOptions): Pr
|
||||
rooms,
|
||||
files,
|
||||
submissions,
|
||||
notes,
|
||||
lessonLog: [],
|
||||
failures,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CrawledBoard, Snapshot } from './crawl.ts';
|
||||
import { h5pSearchText } from './h5p.ts';
|
||||
import { noteSearchText } from './notes.ts';
|
||||
import { matchesAll, snippet, tokenize } from './text.ts';
|
||||
|
||||
/**
|
||||
@@ -18,7 +19,7 @@ export interface Hit {
|
||||
where: string;
|
||||
/** Id to pass to a follow-up tool, with the tool that takes it. */
|
||||
targetId: string;
|
||||
targetKind: 'course' | 'room' | 'board' | 'lesson' | 'task' | 'file';
|
||||
targetKind: 'course' | 'room' | 'board' | 'lesson' | 'task' | 'file' | 'note';
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
@@ -114,6 +115,22 @@ export function searchSnapshot(snapshot: Snapshot, query: string, limit = 50): H
|
||||
matchBoards(room.boards, base, terms, push);
|
||||
}
|
||||
|
||||
// The user's own notes. `courseId` is the subject rather than an id: nothing
|
||||
// follows a note back to a course, and the subject is what makes the hit
|
||||
// readable — "Deutsch — my note" rather than a bare path.
|
||||
for (const note of snapshot.notes) {
|
||||
const haystack = noteSearchText(note);
|
||||
if (!matchesAll(haystack, terms)) continue;
|
||||
hits.push({
|
||||
courseId: note.courseId ?? '',
|
||||
courseTitle: note.subject ?? 'Notizen',
|
||||
where: `my own note${note.date ? `, ${note.date}` : ''}`,
|
||||
targetId: note.path,
|
||||
targetKind: 'note',
|
||||
snippet: snippet(haystack, terms),
|
||||
});
|
||||
}
|
||||
|
||||
for (const file of snapshot.files) {
|
||||
if (matchesAll(file.record.name, terms)) {
|
||||
hits.push({
|
||||
|
||||
466
src/core/notes.ts
Normal file
466
src/core/notes.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
import { readdir, readFile, stat, mkdir, writeFile, appendFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { schoolToday } from './dates.ts';
|
||||
import { resolveWithin, safeComponent } from './paths.ts';
|
||||
|
||||
/**
|
||||
* The user's own lesson notes: a directory of Markdown files.
|
||||
*
|
||||
* This is the one store here that Schulcloud and WebUntis know nothing about —
|
||||
* what the person in the room wrote down. It exists because the two upstreams
|
||||
* between them still do not answer "what did the teacher actually say", and a
|
||||
* note taken in the lesson is often the only record of it.
|
||||
*
|
||||
* **Plain files, not a table.** The notes have to be writable from a phone in a
|
||||
* classroom and readable when Postgres is down, so the files are the truth and
|
||||
* the index is only a view of them — the same split as `file_texts` and the
|
||||
* mirror. It also makes migrating in a pile of exported Apple Notes a matter of
|
||||
* writing files, and makes the whole store greppable, diffable and syncable by
|
||||
* anything the user already runs.
|
||||
*
|
||||
* Frontmatter is a deliberately small YAML subset (scalars and inline lists),
|
||||
* parsed here rather than by a dependency: notes are hand-written, so a strict
|
||||
* parser that rejects a file is worse than a lax one that keeps the body. A
|
||||
* file with no frontmatter at all is a valid note.
|
||||
*/
|
||||
|
||||
/** Extensions treated as notes. Anything else in the directory is ignored. */
|
||||
const NOTE_EXTENSIONS = ['.md', '.markdown', '.txt'];
|
||||
|
||||
/**
|
||||
* Caps. A notes directory is user-controlled, but it may also be a synced
|
||||
* folder that has just acquired somebody's 400 MB export, and a crawl must not
|
||||
* turn that into an out-of-memory.
|
||||
*/
|
||||
const MAX_NOTE_BYTES = 512 * 1024;
|
||||
const MAX_NOTES = 5_000;
|
||||
const MAX_DEPTH = 8;
|
||||
|
||||
export interface NoteDoc {
|
||||
/**
|
||||
* Path relative to the notes root — `Deutsch/2026-09-15 Erörterung.md`.
|
||||
* This is the note's id: there is no other, and it is what `get_note` takes.
|
||||
*/
|
||||
path: string;
|
||||
title: string;
|
||||
/** The school day the note belongs to, `YYYY-MM-DD`, when it could be determined. */
|
||||
date?: string;
|
||||
/** Free text as the user writes it — "Deutsch", "LF07". Not a Schulcloud id. */
|
||||
subject?: string;
|
||||
/** A Schulcloud course id, when the note names one, so search can group by course. */
|
||||
courseId?: string;
|
||||
tags: string[];
|
||||
/** Where the note came from: `apple-notes`, `add_note`, or absent for a hand-written file. */
|
||||
source?: string;
|
||||
/** The body, without the frontmatter block. */
|
||||
text: string;
|
||||
/** Last write to the file, ISO. Not the lesson date — see `date` for that. */
|
||||
modifiedAt: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface NoteFrontmatter {
|
||||
title?: string;
|
||||
date?: string;
|
||||
subject?: string;
|
||||
courseId?: string;
|
||||
tags?: string[];
|
||||
source?: string;
|
||||
/** Anything else the file carried, preserved so a round trip loses nothing. */
|
||||
extra?: Record<string, string>;
|
||||
}
|
||||
|
||||
// --- reading -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Every note under `root`, newest lesson first.
|
||||
*
|
||||
* Never throws for a missing root: a notes directory that has not been created
|
||||
* yet is an empty one, and the tools say so far better than a crawl that dies.
|
||||
*/
|
||||
export async function readNotes(root: string): Promise<NoteDoc[]> {
|
||||
const paths = await listNotePaths(root);
|
||||
const notes: NoteDoc[] = [];
|
||||
for (const relative of paths) {
|
||||
const note = await readNoteAt(root, relative).catch(() => undefined);
|
||||
if (note) notes.push(note);
|
||||
}
|
||||
return notes.sort(byNewest);
|
||||
}
|
||||
|
||||
/** Relative paths of the note files under `root`, sorted for a stable order. */
|
||||
export async function listNotePaths(root: string): Promise<string[]> {
|
||||
const found: string[] = [];
|
||||
|
||||
const walk = async (relative: string, depth: number): Promise<void> => {
|
||||
if (depth > MAX_DEPTH || found.length >= MAX_NOTES) return;
|
||||
const absolute = relative ? resolveWithin(root, relative) : root;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(absolute, { withFileTypes: true });
|
||||
} catch {
|
||||
// A root that does not exist yet, or a folder we may not read: an
|
||||
// unreadable corner must not cost the notes that are readable.
|
||||
return;
|
||||
}
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
// Dotfiles are the sync tools' own business (.obsidian, .git, .stfolder)
|
||||
// and never a note.
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const child = relative ? `${relative}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) await walk(child, depth + 1);
|
||||
else if (isNoteFile(entry.name) && found.length < MAX_NOTES) found.push(child);
|
||||
}
|
||||
};
|
||||
|
||||
await walk('', 0);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** One note by its relative path. Throws `NoteNotFound` when there is none. */
|
||||
export async function readNoteAt(root: string, relative: string): Promise<NoteDoc> {
|
||||
const absolute = resolveWithin(root, normalizeRelative(relative));
|
||||
let info;
|
||||
try {
|
||||
info = await stat(absolute);
|
||||
} catch {
|
||||
throw new NoteNotFound(relative);
|
||||
}
|
||||
if (!info.isFile()) throw new NoteNotFound(relative);
|
||||
if (info.size > MAX_NOTE_BYTES) {
|
||||
throw new Error(
|
||||
`Note ${relative} is ${Math.round(info.size / 1024)} KB, past the ${MAX_NOTE_BYTES / 1024} KB limit for a note.`,
|
||||
);
|
||||
}
|
||||
const raw = await readFile(absolute, 'utf8');
|
||||
return parseNote(normalizeRelative(relative), raw, { modifiedAt: info.mtime.toISOString(), bytes: info.size });
|
||||
}
|
||||
|
||||
export class NoteNotFound extends Error {
|
||||
readonly path: string;
|
||||
|
||||
constructor(path: string) {
|
||||
super(`No note at "${path}".`);
|
||||
this.name = 'NoteNotFound';
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A file's text as a note.
|
||||
*
|
||||
* Pure, so the whole frontmatter/title/date story is testable without a disk.
|
||||
*/
|
||||
export function parseNote(
|
||||
relative: string,
|
||||
raw: string,
|
||||
stamp: { modifiedAt: string; bytes: number },
|
||||
): NoteDoc {
|
||||
const { front, body } = splitFrontmatter(raw);
|
||||
const fileName = relative.split('/').pop() ?? relative;
|
||||
return {
|
||||
path: relative,
|
||||
title: front.title || headingTitle(body) || titleFromFileName(fileName),
|
||||
// Frontmatter first, then a date the filename starts with. Never the
|
||||
// file's mtime: an import writes every note today, and dating a year of
|
||||
// lessons "today" would make the whole store useless for "what did we do
|
||||
// before the test".
|
||||
...pick('date', front.date ?? dateFromFileName(fileName)),
|
||||
...pick('subject', front.subject ?? subjectFromPath(relative)),
|
||||
...pick('courseId', front.courseId),
|
||||
...pick('source', front.source),
|
||||
tags: front.tags ?? [],
|
||||
text: body.trim(),
|
||||
modifiedAt: stamp.modifiedAt,
|
||||
bytes: stamp.bytes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits `---\nkey: value\n---\n` off the front.
|
||||
*
|
||||
* Only a leading block counts, and only when it closes: a note that happens to
|
||||
* begin with a horizontal rule keeps its text rather than losing half of it.
|
||||
*/
|
||||
export function splitFrontmatter(raw: string): { front: NoteFrontmatter; body: string } {
|
||||
const text = raw.replace(/^\ufeff/, '');
|
||||
const open = /^---[ \t]*\r?\n/.exec(text);
|
||||
if (!open) return { front: {}, body: text };
|
||||
const close = /\r?\n---[ \t]*(\r?\n|$)/.exec(text.slice(open[0].length - 1));
|
||||
if (!close) return { front: {}, body: text };
|
||||
|
||||
const end = open[0].length - 1 + close.index;
|
||||
const block = text.slice(open[0].length, end);
|
||||
const rest = text.slice(end + close[0].length);
|
||||
|
||||
const front: NoteFrontmatter = {};
|
||||
const extra: Record<string, string> = {};
|
||||
for (const line of block.split(/\r?\n/)) {
|
||||
const match = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line.trim());
|
||||
if (!match) continue;
|
||||
const key = match[1]!.toLowerCase();
|
||||
const value = unquote(match[2]!.trim());
|
||||
if (!value) continue;
|
||||
switch (key) {
|
||||
case 'title':
|
||||
front.title = value;
|
||||
break;
|
||||
case 'date':
|
||||
front.date = normalizeDate(value);
|
||||
break;
|
||||
case 'subject':
|
||||
case 'fach':
|
||||
front.subject = value;
|
||||
break;
|
||||
case 'courseid':
|
||||
case 'course':
|
||||
front.courseId = value;
|
||||
break;
|
||||
case 'source':
|
||||
front.source = value;
|
||||
break;
|
||||
case 'tags':
|
||||
front.tags = parseList(value);
|
||||
break;
|
||||
default:
|
||||
extra[key] = value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(extra).length > 0) front.extra = extra;
|
||||
return { front, body: rest };
|
||||
}
|
||||
|
||||
// --- writing -------------------------------------------------------------
|
||||
|
||||
export interface NoteInput {
|
||||
title: string;
|
||||
text: string;
|
||||
/** The lesson's day. Defaults to today in the school's timezone. */
|
||||
date?: string;
|
||||
subject?: string;
|
||||
courseId?: string;
|
||||
tags?: string[];
|
||||
source?: string;
|
||||
/** Write here instead of deriving a path from subject, date and title. */
|
||||
path?: string;
|
||||
/**
|
||||
* Add to the note at that path if it already exists, rather than creating a
|
||||
* second one. This is what makes a lesson's notes accumulate in one file as
|
||||
* they are taken, which is how anyone actually takes them.
|
||||
*/
|
||||
append?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a note, or appends to one.
|
||||
*
|
||||
* Every component of the path goes through `safeComponent`: the title and
|
||||
* subject arrive from a tool call, so they are untrusted input that becomes a
|
||||
* filename, exactly as course titles do in the mirror.
|
||||
*/
|
||||
export async function writeNote(root: string, input: NoteInput): Promise<{ note: NoteDoc; appended: boolean }> {
|
||||
const date = input.date ?? schoolToday();
|
||||
// Appending is about a *lesson*, not about a title: "note this down too" in
|
||||
// the middle of Tuesday's German lesson means the note already open for
|
||||
// Tuesday and German, whatever it happens to be called. Deriving the path
|
||||
// from the new title instead would start a second note every time, which is
|
||||
// the one thing append exists to prevent.
|
||||
const relative = input.path
|
||||
? normalizeRelative(input.path)
|
||||
: ((input.append ? await noteForLesson(root, date, input.subject) : undefined) ??
|
||||
notePathFor({ date, subject: input.subject, title: input.title }));
|
||||
const absolute = resolveWithin(root, relative);
|
||||
|
||||
const existing = await stat(absolute).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
if (existing && input.append) {
|
||||
// A heading rather than a bare paragraph, so a note built from four
|
||||
// appends still reads as four things and not as one run-on.
|
||||
await appendFile(absolute, `\n\n## ${input.title}\n\n${input.text.trim()}\n`, 'utf8');
|
||||
return { note: await readNoteAt(root, relative), appended: true };
|
||||
}
|
||||
|
||||
// Never overwrite: a note is the only copy of what someone wrote down, and a
|
||||
// second note with the same title on the same day is a normal thing to have.
|
||||
const target = existing ? await freePath(root, relative) : relative;
|
||||
await mkdir(dirname(resolveWithin(root, target)), { recursive: true });
|
||||
await writeFile(
|
||||
resolveWithin(root, target),
|
||||
renderNote(
|
||||
{
|
||||
title: input.title,
|
||||
date,
|
||||
...pick('subject', input.subject),
|
||||
...pick('courseId', input.courseId),
|
||||
...pick('source', input.source),
|
||||
...(input.tags && input.tags.length > 0 ? { tags: input.tags } : {}),
|
||||
},
|
||||
input.text,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
return { note: await readNoteAt(root, target), appended: false };
|
||||
}
|
||||
|
||||
/** A note as it is stored: frontmatter, then the body. */
|
||||
export function renderNote(front: NoteFrontmatter, body: string): string {
|
||||
const lines = [
|
||||
front.title !== undefined && `title: ${quote(front.title)}`,
|
||||
front.date !== undefined && `date: ${front.date}`,
|
||||
front.subject !== undefined && `subject: ${quote(front.subject)}`,
|
||||
front.courseId !== undefined && `courseId: ${front.courseId}`,
|
||||
front.tags && front.tags.length > 0 && `tags: [${front.tags.map((tag) => quote(tag)).join(', ')}]`,
|
||||
front.source !== undefined && `source: ${quote(front.source)}`,
|
||||
...Object.entries(front.extra ?? {}).map(([key, value]) => `${key}: ${quote(value)}`),
|
||||
].filter((line): line is string => typeof line === 'string');
|
||||
return `---\n${lines.join('\n')}\n---\n\n${body.trim()}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a new note goes: `Deutsch/2026-09-15 Erörterung.md`.
|
||||
*
|
||||
* Subject-first because that is how anyone looks for a note by hand, and the
|
||||
* date leads the filename so a folder sorts chronologically in every file
|
||||
* browser there is.
|
||||
*/
|
||||
export function notePathFor(input: { date: string; subject?: string; title: string }): string {
|
||||
const folder = safeComponent(input.subject ?? 'Allgemein', 'Allgemein');
|
||||
const name = safeComponent(`${input.date} ${input.title}`, input.date);
|
||||
return `${folder}/${name}.md`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The note already written for this day and subject, if there is one.
|
||||
*
|
||||
* The newest by path, so a day that somehow grew two notes still gets the one
|
||||
* a person would reach for.
|
||||
*/
|
||||
async function noteForLesson(root: string, date: string, subject: string | undefined): Promise<string | undefined> {
|
||||
const wanted = subject?.trim().toLowerCase();
|
||||
const candidates = (await readNotes(root)).filter(
|
||||
(note) => note.date === date && (note.subject ?? '').toLowerCase() === (wanted ?? ''),
|
||||
);
|
||||
return candidates[0]?.path;
|
||||
}
|
||||
|
||||
/** `note.md` → `note 2.md`, for the day someone titles two notes the same. */
|
||||
async function freePath(root: string, relative: string): Promise<string> {
|
||||
const dot = relative.lastIndexOf('.');
|
||||
const stem = dot > 0 ? relative.slice(0, dot) : relative;
|
||||
const ext = dot > 0 ? relative.slice(dot) : '';
|
||||
for (let n = 2; n < 100; n++) {
|
||||
const candidate = `${stem} ${n}${ext}`;
|
||||
const taken = await stat(resolveWithin(root, candidate)).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (!taken) return candidate;
|
||||
}
|
||||
throw new Error(`Too many notes named like ${relative}.`);
|
||||
}
|
||||
|
||||
// --- matching ------------------------------------------------------------
|
||||
|
||||
/** Everything about a note that search should look at, as one string. */
|
||||
export function noteSearchText(note: NoteDoc): string {
|
||||
return [note.title, note.subject, note.tags.join(' '), note.text].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
/** Filters a list the way `list_notes` does. Pure, and shared with the CLI. */
|
||||
export function filterNotes(
|
||||
notes: NoteDoc[],
|
||||
filter: { subject?: string; since?: string; until?: string; courseId?: string },
|
||||
): NoteDoc[] {
|
||||
const subject = filter.subject?.trim().toLowerCase();
|
||||
return notes.filter((note) => {
|
||||
if (subject && !(note.subject ?? '').toLowerCase().includes(subject)) return false;
|
||||
if (filter.courseId && note.courseId !== filter.courseId) return false;
|
||||
// A note with no date cannot be excluded by a date window without
|
||||
// silently hiding it; undated notes always pass.
|
||||
if (filter.since && note.date && note.date < filter.since) return false;
|
||||
if (filter.until && note.date && note.date > filter.until) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// --- helpers -------------------------------------------------------------
|
||||
|
||||
function isNoteFile(name: string): boolean {
|
||||
const lower = name.toLowerCase();
|
||||
return NOTE_EXTENSIONS.some((extension) => lower.endsWith(extension));
|
||||
}
|
||||
|
||||
/**
|
||||
* A caller's path in the one form the rest of this module uses.
|
||||
*
|
||||
* Leading slashes and backslashes are accepted and normalised because people
|
||||
* paste `/Deutsch/…` from a listing; traversal is not — `resolveWithin` refuses
|
||||
* it, and this must not quietly make it look legal first.
|
||||
*/
|
||||
export function normalizeRelative(path: string): string {
|
||||
return path.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/').trim();
|
||||
}
|
||||
|
||||
function byNewest(a: NoteDoc, b: NoteDoc): number {
|
||||
// Undated notes sort last: they are usually imports that never carried a
|
||||
// date, and they should not head a list of "the last few lessons".
|
||||
if (a.date && b.date && a.date !== b.date) return b.date.localeCompare(a.date);
|
||||
if (a.date && !b.date) return -1;
|
||||
if (!a.date && b.date) return 1;
|
||||
return b.modifiedAt.localeCompare(a.modifiedAt) || a.path.localeCompare(b.path);
|
||||
}
|
||||
|
||||
function pick<K extends string>(key: K, value: string | undefined): Partial<Record<K, string>> {
|
||||
return value ? ({ [key]: value } as Record<K, string>) : {};
|
||||
}
|
||||
|
||||
function headingTitle(body: string): string | undefined {
|
||||
const match = /^\s*#\s+(.+)$/m.exec(body);
|
||||
return match?.[1]?.trim();
|
||||
}
|
||||
|
||||
function titleFromFileName(fileName: string): string {
|
||||
const withoutExtension = fileName.replace(/\.(md|markdown|txt)$/i, '');
|
||||
return withoutExtension.replace(/^\d{4}-\d{2}-\d{2}[ _-]*/, '').trim() || withoutExtension;
|
||||
}
|
||||
|
||||
function dateFromFileName(fileName: string): string | undefined {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(fileName);
|
||||
return match ? `${match[1]}-${match[2]}-${match[3]}` : undefined;
|
||||
}
|
||||
|
||||
/** The first folder is the subject, by the layout `notePathFor` writes. */
|
||||
function subjectFromPath(relative: string): string | undefined {
|
||||
const parts = relative.split('/');
|
||||
return parts.length > 1 ? parts[0] : undefined;
|
||||
}
|
||||
|
||||
/** `15.09.2026` and `2026-09-15T08:00:00Z` both mean the same school day. */
|
||||
function normalizeDate(value: string): string | undefined {
|
||||
const german = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(value);
|
||||
if (german) return `${german[3]}-${german[2]!.padStart(2, '0')}-${german[1]!.padStart(2, '0')}`;
|
||||
const iso = /^(\d{4}-\d{2}-\d{2})/.exec(value);
|
||||
return iso ? iso[1] : undefined;
|
||||
}
|
||||
|
||||
function parseList(value: string): string[] {
|
||||
const inner = /^\[(.*)\]$/.exec(value)?.[1] ?? value;
|
||||
return inner
|
||||
.split(',')
|
||||
.map((entry) => unquote(entry.trim()))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function unquote(value: string): string {
|
||||
const match = /^(['"])(.*)\1$/.exec(value);
|
||||
return match ? match[2]! : value;
|
||||
}
|
||||
|
||||
/** Quotes only when the value would otherwise change meaning on the way back in. */
|
||||
function quote(value: string): string {
|
||||
const clean = value.replace(/[\r\n]+/g, ' ').trim();
|
||||
return /^[\w äöüÄÖÜß.,/()+-]+$/.test(clean) && !/^\[/.test(clean) ? clean : JSON.stringify(clean);
|
||||
}
|
||||
178
src/core/untis-history.ts
Normal file
178
src/core/untis-history.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user