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>
222 lines
9.2 KiB
TypeScript
222 lines
9.2 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 {
|
|
filterNotes,
|
|
NoteNotFound,
|
|
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('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']);
|
|
});
|
|
});
|