The notes existed but there was nowhere to write them: a CLI command on a laptop, a tool call through Claude, or a file in a Docker volume. None of those is reachable from a phone in a lesson, which is where notes are actually taken. So: `/app`, served only when WEB_PASSWORD is set. A login, the day's notes, and a settings page for the Schulcloud token — the one surface here meant for a person rather than a program. The shape follows how the notes are written: one note per school day, one `##` heading per lesson, prose and lists and tables beneath. That turns out to be the design decision that matters, twice over. First, it is what lets WebUntis earn its keep. Opening a day with no note fills in that day's lessons — numbered, with times, teacher and room, cancellations dropped and substitutions marked. Retyping the timetable is exactly the work the second upstream exists to avoid, and "Stunden ergänzen" tops up a note started before the day ended without touching what is already written. Second, it changes how notes are indexed. A day note is indexed per lesson, not whole: search answers "my own note, Deutsch, 18.09.2026" rather than "my own note, Friday", and `list_notes subject=Deutsch` finds a day whose frontmatter names no subject at all. Indexed whole, every hit would read as a weekday and "what did we do in Deutsch" would match notes whose other five lessons were something else. `lessonHeading` and `subjectFromHeading` are a loop — the app writes the heading, the indexer reads the subject back out — and a test holds them to it. Notes taken in a lesson cannot be retaken, so the editor is built around not losing them: autosave, every keystroke mirrored to local storage, a save when the phone locks, and a fallback to the local copy when the request never arrives. A save that would overwrite a version the editor never saw is refused and the choice handed back — the notes folder is synced and open in more than one place, and a phone must not silently win over a laptop. `replaceNote` is separate from `writeNote` for that reason: never-overwrite is right for `add_note` and exactly wrong for an editor. WEB_PASSWORD is the first credential here a human types, so it is the first that can be guessed: scrypt at startup, never stored or compared in the clear, per-address rate limiting — which is not decoration, since the scrypt cost is itself a denial-of-service vector without it. The session is a signed HttpOnly SameSite=Strict cookie whose key is derived from the password, so changing it logs everyone out and there is no second secret to keep. It opens /api, because a session is the user, and never /mcp, because nothing in a browser speaks MCP. Also here, because the app made them matter: frontmatter now reads the indented `- item` list form editors write, so an Obsidian vault round-trips its tags; and a four-digit folder is a filing scheme, not a subject, so `2026/` does not file a school year under one. 357 tests; 106/107 smoke against the local instance, the one failure being the H5P service that instance does not run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
364 lines
14 KiB
TypeScript
364 lines
14 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import { mkdtemp, mkdir, writeFile, readFile } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { describe, it } from 'node:test';
|
||
import {
|
||
dayNotePath,
|
||
filterNotes,
|
||
NoteConflict,
|
||
NoteNotFound,
|
||
noteSections,
|
||
noteSubjects,
|
||
replaceNote,
|
||
subjectFromHeading,
|
||
notePathFor,
|
||
parseNote,
|
||
readNoteAt,
|
||
readNotes,
|
||
renderNote,
|
||
splitFrontmatter,
|
||
writeNote,
|
||
} from '../src/core/notes.ts';
|
||
|
||
const STAMP = { modifiedAt: '2026-09-15T10:00:00.000Z', bytes: 100 };
|
||
|
||
async function root(): Promise<string> {
|
||
return mkdtemp(join(tmpdir(), 'schulcloud-notes-'));
|
||
}
|
||
|
||
describe('splitFrontmatter', () => {
|
||
it('reads a leading block and keeps the body', () => {
|
||
const { front, body } = splitFrontmatter('---\ntitle: Erörterung\ndate: 2026-09-15\n---\n\nText hier.\n');
|
||
assert.equal(front.title, 'Erörterung');
|
||
assert.equal(front.date, '2026-09-15');
|
||
assert.equal(body.trim(), 'Text hier.');
|
||
});
|
||
|
||
it('leaves a note that merely starts with a rule alone', () => {
|
||
// A horizontal rule with no closing fence must not eat the note.
|
||
const { front, body } = splitFrontmatter('---\nkein Frontmatter, nur ein Strich\n');
|
||
assert.deepEqual(front, {});
|
||
assert.match(body, /kein Frontmatter/);
|
||
});
|
||
|
||
it('accepts a note with no frontmatter at all', () => {
|
||
const { front, body } = splitFrontmatter('# Titel\n\nText.');
|
||
assert.deepEqual(front, {});
|
||
assert.equal(body, '# Titel\n\nText.');
|
||
});
|
||
|
||
it('takes a German date and an inline tag list', () => {
|
||
const { front } = splitFrontmatter('---\ndate: 15.09.2026\ntags: [klausur, "aufsatz"]\nfach: Deutsch\n---\nx');
|
||
assert.equal(front.date, '2026-09-15');
|
||
assert.deepEqual(front.tags, ['klausur', 'aufsatz']);
|
||
// "fach" is the German spelling of subject and has to mean the same thing.
|
||
assert.equal(front.subject, 'Deutsch');
|
||
});
|
||
|
||
it('keeps unknown keys rather than dropping them', () => {
|
||
const { front } = splitFrontmatter('---\ntitle: T\nlehrer: Frau Meier\n---\nx');
|
||
assert.equal(front.extra?.lehrer, 'Frau Meier');
|
||
});
|
||
});
|
||
|
||
describe('parseNote', () => {
|
||
it('falls back to the heading, then to the filename, for a title', () => {
|
||
assert.equal(parseNote('a.md', '# Kryptografie\n\nText', STAMP).title, 'Kryptografie');
|
||
assert.equal(parseNote('Deutsch/2026-09-15 Erörterung.md', 'nur Text', STAMP).title, 'Erörterung');
|
||
});
|
||
|
||
it('takes the date from the filename when the frontmatter has none', () => {
|
||
assert.equal(parseNote('Deutsch/2026-09-15 Erörterung.md', 'x', STAMP).date, '2026-09-15');
|
||
});
|
||
|
||
it('never dates a note from its mtime', () => {
|
||
// An import writes every file today; dating a year of lessons "today"
|
||
// would make the whole store useless for revision.
|
||
assert.equal(parseNote('lose Notiz.md', 'x', STAMP).date, undefined);
|
||
});
|
||
|
||
it('takes the folder as the subject', () => {
|
||
assert.equal(parseNote('LF07/2026-09-15 Netze.md', 'x', STAMP).subject, 'LF07');
|
||
assert.equal(parseNote('lose.md', 'x', STAMP).subject, undefined);
|
||
});
|
||
|
||
it('round-trips through renderNote', () => {
|
||
const rendered = renderNote({ title: 'Erörterung', date: '2026-09-15', subject: 'Deutsch', tags: ['klausur'] }, 'Body');
|
||
const note = parseNote('Deutsch/x.md', rendered, STAMP);
|
||
assert.equal(note.title, 'Erörterung');
|
||
assert.equal(note.date, '2026-09-15');
|
||
assert.equal(note.subject, 'Deutsch');
|
||
assert.deepEqual(note.tags, ['klausur']);
|
||
assert.equal(note.text, 'Body');
|
||
});
|
||
});
|
||
|
||
describe('notePathFor', () => {
|
||
it('is subject then date then title', () => {
|
||
assert.equal(notePathFor({ date: '2026-09-15', subject: 'Deutsch', title: 'Erörterung' }), 'Deutsch/2026-09-15 Erörterung.md');
|
||
});
|
||
|
||
it('reduces a hostile title to one component', () => {
|
||
// The title comes from a tool call, so it is untrusted input that becomes
|
||
// a filename — the same boundary the file mirror has.
|
||
const path = notePathFor({ date: '2026-09-15', subject: '../../etc', title: '../../.ssh/authorized_keys' });
|
||
assert.equal(path.split('/').length, 2);
|
||
assert.ok(!path.includes('..'), path);
|
||
});
|
||
});
|
||
|
||
describe('readNotes', () => {
|
||
it('is empty, not an error, for a directory that does not exist', async () => {
|
||
assert.deepEqual(await readNotes(join(tmpdir(), 'schulcloud-notes-absent-xyz')), []);
|
||
});
|
||
|
||
it('walks folders, skips dotfiles and non-notes, and sorts newest first', async () => {
|
||
const dir = await root();
|
||
await mkdir(join(dir, 'Deutsch'), { recursive: true });
|
||
await mkdir(join(dir, '.obsidian'), { recursive: true });
|
||
await writeFile(join(dir, 'Deutsch', '2026-09-15 Erörterung.md'), 'A');
|
||
await writeFile(join(dir, 'Deutsch', '2026-09-22 Analyse.md'), 'B');
|
||
await writeFile(join(dir, '.obsidian', 'workspace.md'), 'nope');
|
||
await writeFile(join(dir, 'bild.png'), 'nope');
|
||
|
||
const notes = await readNotes(dir);
|
||
assert.deepEqual(notes.map((note) => note.title), ['Analyse', 'Erörterung']);
|
||
});
|
||
|
||
it('refuses to read its way out of the root', async () => {
|
||
const dir = await root();
|
||
await assert.rejects(() => readNoteAt(dir, '../../etc/passwd'), /traversal/);
|
||
});
|
||
|
||
it('reports a missing note as missing', async () => {
|
||
const dir = await root();
|
||
await assert.rejects(() => readNoteAt(dir, 'Deutsch/nichts.md'), NoteNotFound);
|
||
});
|
||
});
|
||
|
||
describe('writeNote', () => {
|
||
it('creates a note with frontmatter at the derived path', async () => {
|
||
const dir = await root();
|
||
const { note } = await writeNote(dir, { title: 'Erörterung', text: 'Aufbau: These, Argument, Fazit.', subject: 'Deutsch', date: '2026-09-15' });
|
||
assert.equal(note.path, 'Deutsch/2026-09-15 Erörterung.md');
|
||
assert.equal(note.subject, 'Deutsch');
|
||
assert.match(await readFile(join(dir, note.path), 'utf8'), /^---\ntitle: Erörterung\n/);
|
||
});
|
||
|
||
it('appends to the same file when asked, so a lesson stays one note', async () => {
|
||
const dir = await root();
|
||
await writeNote(dir, { title: 'Erörterung', text: 'Erstens.', subject: 'Deutsch', date: '2026-09-15' });
|
||
const { note, appended } = await writeNote(dir, {
|
||
title: 'Nachtrag', text: 'Zweitens.', subject: 'Deutsch', date: '2026-09-15',
|
||
path: 'Deutsch/2026-09-15 Erörterung.md', append: true,
|
||
});
|
||
assert.equal(appended, true);
|
||
assert.match(note.text, /Erstens\./);
|
||
assert.match(note.text, /Zweitens\./);
|
||
assert.equal((await readNotes(dir)).length, 1);
|
||
});
|
||
|
||
it('appends by lesson, not by title — a second note in the same lesson has another name', async () => {
|
||
// "halt das auch noch fest" mid-lesson carries a new title; deriving the
|
||
// path from it would start a second note every time, which is the one
|
||
// thing append exists to prevent.
|
||
const dir = await root();
|
||
await writeNote(dir, { title: 'Erörterung', text: 'Erstens.', subject: 'Deutsch', date: '2026-09-15' });
|
||
const { note, appended } = await writeNote(dir, {
|
||
title: 'Nachtrag', text: 'Zweitens.', subject: 'Deutsch', date: '2026-09-15', append: true,
|
||
});
|
||
assert.equal(appended, true);
|
||
assert.equal(note.path, 'Deutsch/2026-09-15 Erörterung.md');
|
||
assert.equal((await readNotes(dir)).length, 1);
|
||
});
|
||
|
||
it('creates the note when append finds nothing to append to', async () => {
|
||
const dir = await root();
|
||
const { note, appended } = await writeNote(dir, { title: 'Erstes', text: 'x', subject: 'Deutsch', date: '2026-09-15', append: true });
|
||
assert.equal(appended, false);
|
||
assert.equal(note.path, 'Deutsch/2026-09-15 Erstes.md');
|
||
});
|
||
|
||
it('does not append across days or subjects', async () => {
|
||
const dir = await root();
|
||
await writeNote(dir, { title: 'Montag', text: 'a', subject: 'Deutsch', date: '2026-09-15' });
|
||
const otherDay = await writeNote(dir, { title: 'Dienstag', text: 'b', subject: 'Deutsch', date: '2026-09-16', append: true });
|
||
const otherSubject = await writeNote(dir, { title: 'Netze', text: 'c', subject: 'LF07', date: '2026-09-15', append: true });
|
||
assert.equal(otherDay.appended, false);
|
||
assert.equal(otherSubject.appended, false);
|
||
assert.equal((await readNotes(dir)).length, 3);
|
||
});
|
||
|
||
it('never overwrites: a second note of the same name gets its own file', async () => {
|
||
const dir = await root();
|
||
await writeNote(dir, { title: 'Test', text: 'eins', subject: 'Deutsch', date: '2026-09-15' });
|
||
const { note, appended } = await writeNote(dir, { title: 'Test', text: 'zwei', subject: 'Deutsch', date: '2026-09-15' });
|
||
assert.equal(appended, false);
|
||
assert.equal(note.path, 'Deutsch/2026-09-15 Test 2.md');
|
||
assert.equal((await readNotes(dir)).length, 2);
|
||
});
|
||
|
||
it('cannot be steered out of the notes root by its title', async () => {
|
||
const dir = await root();
|
||
const { note } = await writeNote(dir, { title: '../../escape', text: 'x', subject: '..', date: '2026-09-15' });
|
||
assert.ok(!note.path.includes('..'), note.path);
|
||
assert.equal((await readNotes(dir)).length, 1);
|
||
});
|
||
});
|
||
|
||
describe('splitFrontmatter: block lists', () => {
|
||
it('reads tags written as indented "- item" lines, which is how editors write them', () => {
|
||
// Obsidian and most YAML front ends write a list this way; reading only
|
||
// the inline form silently dropped every tag such an editor had written.
|
||
const { front } = splitFrontmatter('---\ntitle: T\ntags:\n - klausur\n - aufsatz\n---\nx');
|
||
assert.deepEqual(front.tags, ['klausur', 'aufsatz']);
|
||
assert.equal(front.title, 'T');
|
||
});
|
||
|
||
it('stops the list at the next key', () => {
|
||
const { front } = splitFrontmatter('---\ntags:\n - eins\nsubject: Deutsch\n---\nx');
|
||
assert.deepEqual(front.tags, ['eins']);
|
||
assert.equal(front.subject, 'Deutsch');
|
||
});
|
||
});
|
||
|
||
describe('a note per school day', () => {
|
||
const day = parseNote(
|
||
'2026/2026-09-18.md',
|
||
[
|
||
'## 1. Deutsch — 08:00–08:45 · MEI',
|
||
'',
|
||
'Erörterung: These, Argument, Fazit.',
|
||
'',
|
||
'### Aufbau',
|
||
'',
|
||
'- Gegenargument nicht vergessen',
|
||
'',
|
||
'## 2. LF07 — 08:50–09:35 · Sb',
|
||
'',
|
||
'/24 = 254 nutzbare Adressen',
|
||
].join('\n'),
|
||
STAMP,
|
||
);
|
||
|
||
it('does not take the year folder for a subject', () => {
|
||
// "2026/" is a filing scheme, not a lesson.
|
||
assert.equal(day.subject, undefined);
|
||
});
|
||
|
||
it('splits into one section per lesson', () => {
|
||
assert.deepEqual(noteSections(day).map((section) => section.subject), ['Deutsch', 'LF07']);
|
||
});
|
||
|
||
it('keeps subheadings inside their lesson', () => {
|
||
const first = noteSections(day)[0]!;
|
||
assert.match(first.text, /### Aufbau/);
|
||
assert.doesNotMatch(first.text, /LF07/);
|
||
});
|
||
|
||
it('reports every subject the day covers', () => {
|
||
assert.deepEqual(noteSubjects(day), ['Deutsch', 'LF07']);
|
||
});
|
||
|
||
it('is found by a subject filter, which only its headings know', () => {
|
||
assert.equal(filterNotes([day], { subject: 'lf07' }).length, 1);
|
||
assert.equal(filterNotes([day], { subject: 'Mathe' }).length, 0);
|
||
});
|
||
|
||
it('does not split on a ## inside a fenced code block', () => {
|
||
const note = parseNote('2026/2026-09-18.md', '## Info\n\n```\n## nicht eine Stunde\n```\n', STAMP);
|
||
assert.equal(noteSections(note).length, 1);
|
||
});
|
||
|
||
it('has no sections when it is one piece of prose, as an imported note is', () => {
|
||
assert.deepEqual(noteSections(parseNote('Deutsch/2026-09-15 A.md', 'Nur Text.', STAMP)), []);
|
||
});
|
||
});
|
||
|
||
describe('subjectFromHeading', () => {
|
||
it('reads the subject out of every shape the page and a person write', () => {
|
||
for (const [heading, expected] of [
|
||
['1. Deutsch — 08:00–08:45 · MEI · R 204', 'Deutsch'],
|
||
['2) LF07', 'LF07'],
|
||
['Deutsch', 'Deutsch'],
|
||
['08:00 Deutsch', 'Deutsch'],
|
||
['3. Mathe (Vertretung)', 'Mathe'],
|
||
] as const) {
|
||
assert.equal(subjectFromHeading(heading), expected, heading);
|
||
}
|
||
});
|
||
|
||
it('names no subject rather than a wrong one', () => {
|
||
for (const heading of ['1.', '08:00–08:45', '—', '###']) {
|
||
assert.equal(subjectFromHeading(heading), undefined, heading);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('dayNotePath', () => {
|
||
it('files a day under its year', () => {
|
||
assert.equal(dayNotePath('2026-09-18'), '2026/2026-09-18.md');
|
||
});
|
||
});
|
||
|
||
describe('replaceNote', () => {
|
||
it('overwrites, which is what saving from an editor means', async () => {
|
||
const dir = await root();
|
||
await replaceNote(dir, dayNotePath('2026-09-18'), { title: 'Freitag', text: 'eins', date: '2026-09-18' });
|
||
const note = await replaceNote(dir, dayNotePath('2026-09-18'), { title: 'Freitag', text: 'zwei', date: '2026-09-18' });
|
||
assert.equal(note.text, 'zwei');
|
||
assert.equal((await readNotes(dir)).length, 1, 'saving twice is one note, not two');
|
||
});
|
||
|
||
it('refuses a save that would clobber a version the editor never saw', async () => {
|
||
// The notes folder is synced and open in more than one place; a phone must
|
||
// not silently win over a laptop.
|
||
const dir = await root();
|
||
const first = await replaceNote(dir, 'x.md', { title: 'X', text: 'vom Laptop' });
|
||
await assert.rejects(
|
||
() => replaceNote(dir, 'x.md', { title: 'X', text: 'vom Handy' }, { expectedModifiedAt: '2020-01-01T00:00:00.000Z' }),
|
||
NoteConflict,
|
||
);
|
||
assert.equal((await readNoteAt(dir, 'x.md')).text, 'vom Laptop', 'the refused save changed nothing');
|
||
assert.ok(first.modifiedAt);
|
||
});
|
||
|
||
it('accepts a save carrying the modification time it loaded', async () => {
|
||
const dir = await root();
|
||
const loaded = await replaceNote(dir, 'x.md', { title: 'X', text: 'eins' });
|
||
const saved = await replaceNote(dir, 'x.md', { title: 'X', text: 'zwei' }, { expectedModifiedAt: loaded.modifiedAt });
|
||
assert.equal(saved.text, 'zwei');
|
||
});
|
||
|
||
it('creates the note when there is none, with nothing to clash against', async () => {
|
||
const dir = await root();
|
||
const note = await replaceNote(dir, dayNotePath('2026-09-18'), { title: 'Freitag', text: 'neu' }, { expectedModifiedAt: '2020-01-01T00:00:00.000Z' });
|
||
assert.equal(note.text, 'neu');
|
||
});
|
||
|
||
it('cannot be steered out of the notes root', async () => {
|
||
const dir = await root();
|
||
await assert.rejects(() => replaceNote(dir, '../escape.md', { title: 'X', text: 'x' }), /traversal/);
|
||
});
|
||
});
|
||
|
||
describe('filterNotes', () => {
|
||
const notes = [
|
||
parseNote('Deutsch/2026-09-15 A.md', 'a', STAMP),
|
||
parseNote('LF07/2026-09-22 B.md', 'b', STAMP),
|
||
parseNote('lose.md', 'c', STAMP),
|
||
];
|
||
|
||
it('matches a subject as a fragment', () => {
|
||
assert.deepEqual(filterNotes(notes, { subject: 'deut' }).map((note) => note.title), ['A']);
|
||
});
|
||
|
||
it('keeps undated notes inside a date window rather than hiding them', () => {
|
||
// Excluding them would silently drop every note that arrived without a
|
||
// date, which is most of an Apple Notes import.
|
||
const titles = filterNotes(notes, { since: '2026-09-20' }).map((note) => note.title);
|
||
assert.deepEqual(titles, ['B', 'lose']);
|
||
});
|
||
});
|