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, plainText, readNoteAt, readNotes, renderNote, searchNotes, 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('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']); }); }); describe('searchNotes', () => { const day = parseNote( '2026/2026-09-04.md', [ '---', 'title: Freitag, 04.09.2026', 'date: 2026-09-04', '---', '', '## 1. LF10 — 08:00–08:45', '', 'Normalisierung: erste, zweite und dritte Normalform.', '', '## 2. Deutsch — 08:50–09:35', '', 'Erörterung: These, Argument, Fazit.', ].join('\n'), new Date(), 0, ); const loose = parseNote( 'Deutsch/2026-09-15 Aufbau.md', ['---', 'title: Aufbau', 'subject: Deutsch', '---', '', 'Gegenargument nicht vergessen.'].join('\n'), new Date(), 0, ); it('answers with the lesson, not the day', () => { const [hit] = searchNotes([day], 'Normalform'); assert.equal(hit?.heading, '1. LF10 — 08:00–08:45'); assert.equal(hit?.subject, 'LF10'); assert.equal(hit?.date, '2026-09-04'); }); it('does not report a day because another of its lessons matched', () => { // The whole reason a day note is searched per section: "Erörterung" is // Deutsch, and reporting it as LF10 would be worse than not finding it. const hits = searchNotes([day], 'Erörterung'); assert.equal(hits.length, 1); assert.equal(hits[0]?.subject, 'Deutsch'); }); it('ignores case and accents', () => { assert.equal(searchNotes([day], 'erorterung').length, 1); assert.equal(searchNotes([day], 'ERÖRTERUNG').length, 1); }); it('needs every word, in any order', () => { assert.equal(searchNotes([day], 'normalform erste').length, 1); assert.equal(searchNotes([day], 'normalform erörterung').length, 0); }); it('searches the heading itself, so a subject finds its lessons', () => { assert.equal(searchNotes([day], 'LF10').length, 1); }); it('treats a note without lessons as one piece', () => { const [hit] = searchNotes([loose], 'Gegenargument'); assert.equal(hit?.path, 'Deutsch/2026-09-15 Aufbau.md'); assert.equal(hit?.heading, undefined); assert.equal(hit?.subject, 'Deutsch'); }); it('carries a snippet worth reading', () => { const [hit] = searchNotes([day], 'Normalform'); assert.match(hit!.snippet, /erste, zweite und dritte Normalform/); }); it('shows the snippet as prose, not as Markdown', () => { const table = parseNote( '2026/2026-09-05.md', [ '---', 'title: Samstag', '---', '', '## LF10', '', '| Normalform | Bedingung |', '| --- | --- |', '| 1NF | atomare Werte |', ].join('\n'), new Date(), 0, ); const [hit] = searchNotes([table], '1NF'); // The pipes and the `|---|` rule say nothing to someone reading a result. assert.equal(hit?.snippet, '1NF · atomare Werte'); }); it('strips the markers from a bullet or a heading in the snippet', () => { const [hit] = searchNotes([day], 'Argument'); assert.equal(hit?.snippet.includes('**'), false); }); it('finds nothing for an empty query rather than everything', () => { assert.deepEqual(searchNotes([day, loose], ' '), []); }); it('stops at the limit', () => { assert.equal(searchNotes([day], 'e', 1).length, 1); }); }); describe('plainText', () => { it('reads a table row as its cells', () => { assert.equal(plainText('| 1NF | atomare Werte |'), '1NF · atomare Werte'); assert.equal(plainText('| --- | --- |'), ''); }); it('drops the markers but keeps the words', () => { assert.equal(plainText('- **These**, Argument, _Fazit_'), 'These, Argument, Fazit'); assert.equal(plainText('## 1. Deutsch'), '1. Deutsch'); assert.equal(plainText('> Merksatz'), 'Merksatz'); assert.equal(plainText('- [x] erledigt'), 'erledigt'); }); it('leaves a word with an underscore in it alone', () => { assert.equal(plainText('snake_case_name bleibt'), 'snake_case_name bleibt'); }); it('reads a link as its label and an escape as its character', () => { assert.equal(plainText('[Arbeitsblatt](https://example.org/ab.pdf)'), 'Arbeitsblatt'); assert.equal(plainText('2 \\* 3'), '2 * 3'); }); });