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; } // --- 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 { 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 { const found: string[] = []; const walk = async (relative: string, depth: number): Promise => { 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 { 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 = {}; 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 { 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 { 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(key: K, value: string | undefined): Partial> { return value ? ({ [key]: value } as Record) : {}; } 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); }