Files
Schulcloud-MCP/test/notes.test.ts
MechaCat02 e129fd4b0a Keep a pasted note whole, and search the notes from the app
Two things found by using this on real notes.

**The paste.** Copying out of Apple Notes put most of the note on the
floor. WebKit wraps a copied selection in a single span carrying the
computed style of everything in it — `font-weight: 700` included — with
the real blocks nested inside. The serializer read that span as inline,
so every line collapsed into one paragraph and every word came out bold;
switching to the Markdown view then showed what little had survived,
which is what "most of the text was gone" was. And because the boldness
came from a foreign span's style rather than a tag, the bold button
could not remove it.

The rule now is that an element holding blocks is a block whatever its
tag, and that a container's style is not emphasis — only a span wrapping
a single run of text is. A paste this editor cannot read at all (some
engines withhold the clipboard from the event) is tidied afterwards
instead, but only if something actually arrived, so an empty paste still
costs nothing.

**The search.** A Suche tab over the user's own notes, reading the files
rather than the index: notes reach the index only on a full crawl, so a
lesson written this morning would not be findable this morning, which is
most of what anyone searches their own notes for. A result names the
lesson it matched in, not the day, for the same reason the index indexes
day notes per section. Tapping one opens that day in the editor.

Driven in Firefox against the real app with a proxied session: the
paste, six switches between the two views, bold and unbold on pasted
text, the search, and opening a result. 386 unit tests, 114/115 smoke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:09:47 +02:00

469 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
searchNotes,
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:0008:45 · MEI',
'',
'Erörterung: These, Argument, Fazit.',
'',
'### Aufbau',
'',
'- Gegenargument nicht vergessen',
'',
'## 2. LF07 — 08:5009: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:0008: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:0008: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:0008:45',
'',
'Normalisierung: erste, zweite und dritte Normalform.',
'',
'## 2. Deutsch — 08:5009: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:0008: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);
});
});