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 { 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']); }); });