3 Commits

Author SHA1 Message Date
MechaCat02
48c6cf39d5 Lay the notes screen out like a notes app
The list of notes and the note being written are one screen now, two
panes: the notes on the left, the open one on the right, side by side
where there is room and one at a time on a phone, where the back button
returns to the list.

The search box moved into the top of that list, and its results *are*
the list — searching is a way of finding a note, not a separate place to
be, and a tab for it was a tab too many. Emptying the box brings the
whole list back. Opening a hit opens that day at the lesson that
matched, rather than at the top of a day with six of them.

A row has to say what the note holds, so the listing carries it: the
subjects a day covers, how many lessons, and the first line actually
written in it. One request for the whole list rather than one per note.

`plainText` is now one rule in one place for wherever a note is shown
rather than edited — the search snippet and the list row both went
through their own half-copy of it, and the row's copy rendered a table
as `| | |` and left `_Fazit_` wearing its markers. It strips one leading
marker, not each in turn, because `## 1. Deutsch` keeps its lesson
number and the list rule was eating it.

Driven in Firefox at both widths: the list, the search, opening a hit,
the jump to the lesson, and the phone's list-then-note. 390 unit tests,
116/117 smoke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:22:47 +02:00
MechaCat02
5db098b67f Merge: a pasted note kept whole, and search over the notes
The paste from Apple Notes was losing most of a note and bolding the
rest, which the Markdown view then showed as missing text. An element
holding blocks is now a block whatever its tag, and a container's style
is not emphasis.

With it, the Suche tab: full text over the note files rather than the
index, so a lesson written this morning is findable this morning, and a
result names the lesson rather than the day.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:09:53 +02:00
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
13 changed files with 991 additions and 23 deletions

View File

@@ -125,7 +125,11 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
hit would read "my note, Monday" and "what did we do in Deutsch" would match
a note whose other five lessons were something else. The files are the truth and the
index is a view of them, so `list_notes`/`get_note` read disk and answer
before the first crawl and while Postgres is down. **The one thing anything
before the first crawl and while Postgres is down. `searchNotes` is full
text over those same files, behind `/api/notes/search` and the app's Suche
tab: notes reach the index only on a **full** crawl, so anything written
this week would be missing from it, and the app is exactly where "I wrote
that this morning" is the common case. **The one thing anything
here writes** — see Invariants. `docs/NOTES.md` is the guide.
- **`untis-history.ts`** — the class register read backwards, which is what
puts "what did we actually cover" into the search index. Its whole reason
@@ -163,7 +167,11 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
it against `test/mini-dom.ts`, ~60 lines of read-only DOM, because losing a
lesson's notes to a lossy serializer is not a bug anyone can recover from.
Pasted HTML goes through Markdown before it reaches the document, which is
where sanitising and formatting are the same operation.
where sanitising and formatting are the same operation. **An element holding
blocks is a block, whatever its tag, and a container's style is not
emphasis** — WebKit wraps a copied selection in one span carrying the computed
style of everything in it, so reading that span as inline made a whole pasted
Apple note bold and flattened it into one paragraph.
- **`http/web-auth.ts`** — the app's login, which is a different kind of
credential from everything else here: a password a person types, not a token a
program was configured with. scrypt at startup, a signed `HttpOnly` /

View File

@@ -86,6 +86,12 @@ teachers, rooms, cancellations dropped and substitutions marked. Each heading is
indexed as its own lesson, so a search answers "my own note, Deutsch,
18.09.2026" rather than "Friday".
It reads like a notes app: the notes on the left, the open one on the right, and
a search box above the list. Search goes straight to the files — no crawl in
between, so a lesson written this morning is findable this morning — and a
result names the lesson it matched in rather than the day, opening that day at
that lesson.
Writing is **formatted, not Markdown**: headings, bold, lists, tick boxes,
quotes, links and tables come from a toolbar, and `MD` shows the Markdown
underneath when you want it. The file on disk stays Markdown either way — that

View File

@@ -68,7 +68,22 @@ On a phone it is worth adding to the home screen — it has a manifest and opens
standalone, which is the difference between "a page I have to find" and "the
thing I open in a free period".
**Notizen** is one screen: the day, ` ` to move between days, and the editor.
**Notizen** is one screen in two panes: your notes on the left, the open one on
the right. Side by side where there is room; on a phone the list comes first and
a note pushes over it, with ` Notizen` to come back.
The list is every note, newest first — the day, the lessons it covers, and the
first thing written in it:
```
Freitag, 18.09.2026
Deutsch · LF07
Dreischritt: These, Argument mit Beleg, Fazit.
```
**** opens today, whether or not it has a note yet — the one thing a list of
notes cannot show you, because an empty day is not a note. ` ` and the date
field move between days from there.
- Opening a day with no note yet **fills in that day's lessons from WebUntis**
numbered, with times, teacher and room, cancellations left out and
@@ -105,11 +120,19 @@ tables as tables — and the toolbar above it writes the Markdown. Nobody types
| `🔗` `▦` | a link (`Strg`/`Cmd` + K) and a table |
| `MD` | the Markdown itself |
`Enter` starts a new paragraph, `Shift+Enter` a new line in the same one. A
paste from a web page or a PDF keeps its structure and loses its fonts, colours
and anything else that is not in the list above — pasted HTML is converted to
Markdown before it reaches the page, which is what keeps a copied page from
bringing its script along.
`Enter` starts a new paragraph, `Shift+Enter` a new line in the same one. In a
table, `Tab` walks the cells and a `Tab` out of the last one adds a row;
`Ctrl`/`Cmd`+`Enter` opens a paragraph after whatever block you are in.
A paste from a web page, a PDF or Apple Notes keeps its structure and loses its
fonts, colours and anything else that is not in the list above — pasted HTML is
converted to Markdown before it reaches the page, which is what keeps a copied
page from bringing its script along. **A copy from Apple Notes arrives wrapped
in one span carrying the computed style of everything in it**, `font-weight:
700` included; that wrapper is a container, not emphasis, and the blocks inside
it are blocks. Reading it the other way made a whole pasted note bold and
flattened every line into one paragraph, which is the failure
`test/app-markdown.test.ts` now pins down.
**The file is still Markdown.** `MD` shows it and lets you edit it directly,
which is the way to write something the toolbar has no button for. There is no
@@ -122,6 +145,32 @@ so rather than being quietly reduced. `test/app-markdown.test.ts` is what holds
that promise up: every construct in this document goes in and comes back out
unchanged.
### Searching, in the list
The box above the list searches your own notes and nothing else, and the results
*are* the list — searching is a way of finding a note, not a separate place to
be. Emptying the box brings the whole list back.
Every word has to appear, in any order, ignoring case and accents; there is no
stemming, so *Argument* does not find *Argumente*.
A result names the **lesson**, not the day — `1. LF10 — 08:0008:45` with the
date under it and the matched words marked — because a day note holds five or
six lessons and "Freitag" says nothing about which one matched. Opening a hit
opens that day **at that lesson**. A note that is not a school day, such as one
from the Apple Notes import, opens read-only: the editor is day-shaped and those
notes have no day.
It reads the **files**, not the index. That is the point: notes reach the
Postgres index only on a full crawl, so a lesson written this morning would not
be there, and "what did I write this week" is most of what anyone searches their
own notes for. A few hundred small files answer instantly, and it keeps working
while Postgres is down — the same reason `list_notes` reads disk.
The `search` tool in Claude is the other half: it spans the Schulcloud material
and the WebUntis class register as well, at the cost of being only as fresh as
the last crawl.
**Einstellungen** holds the Schulcloud token: how long it has left, and the box
to paste a fresh `jwt` cookie into when it expires (the same thing `schulcloud
token set` and the older `/token` page do). It also shows the index's state and

View File

@@ -740,7 +740,11 @@ console.log('\n== web app ==');
);
check(
'the shell loads the app as a module, so its imports resolve',
/<script type="module" src="app\.js">/.test(shellText) && shellText.includes('data-command="bold"'),
/<script type="module" src="app\.js">/.test(shellText) &&
shellText.includes('data-command="bold"') &&
shellText.includes('id="search-form"') &&
// The note list and the editor are one screen now, not two tabs.
shellText.includes('id="rail-list"'),
);
const anonymousSession = await (await fetch(`${root}/app/session`)).json();
@@ -829,6 +833,56 @@ console.log('\n== web app ==');
`${bySubject.count} note(s)`,
);
// What a row in the app's note list shows, which is why the listing carries
// it: one request for the whole list rather than one per note.
const listing = await (await fetch(`${root}/api/notes?limit=5`, { headers: withSession })).json();
const dayRow = listing.notes?.find((note) => note.path === '2026/2026-09-18.md');
check(
'the listing says what a note holds, without its body',
dayRow !== undefined && dayRow.text === undefined && dayRow.lessons === 1 && dayRow.subjects?.includes('Geschichte'),
`${dayRow?.lessons} lesson(s), subjects ${dayRow?.subjects?.join('/')}`,
);
check(
'and a preview that reads as prose',
/Weimarer Republik/.test(dayRow?.preview ?? '') && !/[#*|]/.test(dayRow?.preview ?? ''),
dayRow?.preview,
);
// Full text over the files themselves — the app's search box. It reads disk,
// so a note written seconds ago is findable without a crawl, which is the
// whole reason it does not go through the index.
const found = await (
await fetch(`${root}/api/notes/search?q=${encodeURIComponent('Weimarer Scheiterns')}`, { headers: withSession })
).json();
check(
'note search finds a lesson by its text, with no crawl in between',
found.count === 1 && found.hits[0]?.path === '2026/2026-09-18.md',
`${found.count} hit(s)`,
);
check(
'and answers with the lesson rather than the day',
found.hits[0]?.subject === 'Geschichte' && /Geschichte/.test(found.hits[0]?.heading ?? ''),
`${found.hits[0]?.subject}${found.hits[0]?.heading}`,
);
check(
'the snippet reads as prose, not as Markdown',
/Weimarer Republik/.test(found.hits[0]?.snippet ?? '') && !/[#*|]/.test(found.hits[0]?.snippet ?? ''),
found.hits[0]?.snippet,
);
const accents = await (
await fetch(`${root}/api/notes/search?q=${encodeURIComponent('weimarer')}`, { headers: withSession })
).json();
check('search ignores case and accents', accents.count === 1, `${accents.count} hit(s)`);
const bothWords = await (
await fetch(`${root}/api/notes/search?q=${encodeURIComponent('Weimarer Subnetting')}`, { headers: withSession })
).json();
check('every word has to match', bothWords.count === 0, `${bothWords.count} hit(s)`);
const tooShort = await fetch(`${root}/api/notes/search?q=a`, { headers: withSession });
check('a one-letter search is refused rather than reading every note', tooShort.status === 400, `got ${tooShort.status}`);
const badDate = await fetch(`${root}/api/notes/day?date=2026-02-30`, { headers: withSession });
check('a date that does not exist is refused', badDate.status === 400, `got ${badDate.status}`);

View File

@@ -550,6 +550,139 @@ export function filterNotes(
});
}
/** One place a query matched: a lesson, or a whole note that has no lessons. */
export interface NoteHit {
/** The note's path, as `get_note` takes it. */
path: string;
title: string;
date?: string;
/** The lesson's subject, or the note's own. */
subject?: string;
/** The `##` heading the match sits under, when the note has lessons. */
heading?: string;
/** A line or two around the first match, for a result list. */
snippet: string;
}
/**
* Full-text search over the note files themselves.
*
* Deliberately *not* the Postgres index the `search` tool uses. Notes are only
* read by a full crawl, so anything written this week would be missing from it
* — and the one place a person searches their own notes from is the app, where
* "I wrote that this morning" is the common case. A few hundred small files
* read from disk answer in well under the time an index would take to catch up,
* and this keeps working when Postgres is down, which is the same reason
* `list_notes` reads disk.
*
* Matching is by word: every word must appear somewhere in the lesson, in any
* order, ignoring case and accents, so "erorterung aufbau" finds a lesson about
* the Erörterung whose Aufbau was discussed.
*/
export function searchNotes(notes: NoteDoc[], query: string, limit = 50): NoteHit[] {
const words = fold(query)
.split(/\s+/)
.filter((word) => word.length > 0);
if (words.length === 0) return [];
const hits: NoteHit[] = [];
for (const note of notes) {
// A day note answers per lesson, for the same reason the index does: a
// hit that says "my note, Monday" names neither the subject nor what it
// was about.
const sections = noteSections(note);
const pieces =
sections.length > 0
? sections.map((section) => ({
heading: section.heading,
subject: section.subject ?? note.subject,
text: `${section.heading}\n${section.text}`,
}))
: [{ heading: undefined, subject: note.subject, text: `${note.title}\n${note.text}` }];
for (const piece of pieces) {
const haystack = fold(`${piece.text}\n${note.tags.join(' ')}`);
if (!words.every((word) => haystack.includes(word))) continue;
hits.push({
path: note.path,
title: note.title,
...(note.date ? { date: note.date } : {}),
...(piece.subject ? { subject: piece.subject } : {}),
...(piece.heading ? { heading: piece.heading } : {}),
snippet: snippetAround(piece.text, words[0]!),
});
if (hits.length >= limit) return hits;
}
}
return hits;
}
/**
* Case and accents removed, so "Erörterung" and "erorterung" are one word.
*
* The Postgres index does this with a German configuration; here it is plain
* Unicode folding, which is enough for "find the lesson I am thinking of" and
* has no stemming — a search for "Argumente" will not find "Argument".
*/
function fold(value: string): string {
return value
.toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '');
}
/** The line the first word matched, with the next one, as readable prose. */
function snippetAround(text: string, word: string): string {
const lines = text.split('\n').filter((line) => line.trim().length > 0);
const at = lines.findIndex((line) => fold(line).includes(word));
const preview = lines
.slice(Math.max(0, at === -1 ? 0 : at), (at === -1 ? 0 : at) + 2)
.map(plainText)
.join(' ')
.replace(/\s{2,}/g, ' ')
.trim();
return preview.length > 240 ? `${preview.slice(0, 237)}` : preview;
}
/**
* One line of Markdown as the words it holds.
*
* Wherever a note is *shown* rather than edited — a search snippet, a row in
* the app's note list — this is what it goes through. A line is read there,
* not parsed: `| 1NF | atomare Werte |` says more as "1NF · atomare Werte",
* and `_Fazit_` says exactly as much as "Fazit" while looking like a mistake.
*/
export function plainText(line: string): string {
let value = line.trim();
if (/^\|.*\|$/.test(value)) {
// A table row, including the `|---|---|` rule, which says nothing at all.
if (/^\|[\s:|-]*\|$/.test(value)) return '';
value = value.slice(1, -1).split('|').map((cell) => cell.trim()).filter(Boolean).join(' · ');
}
// One leading marker, not all of them in turn: a heading reading
// `## 1. Deutsch` keeps its lesson number, which the list rule would
// otherwise take for a bullet and eat.
for (const marker of [/^#{1,6}\s+/, /^>\s?/, /^[-*+]\s+(\[[ xX]\]\s+)?/, /^\d{1,9}[.)]\s+/]) {
if (marker.test(value)) {
value = value.replace(marker, '');
break;
}
}
return (
value
.replace(/(\*\*|__|~~)/g, '')
// Single markers only where they are emphasis, so snake_case survives.
.replace(/(?<![\w*])\*([^*]+)\*(?![\w*])/g, '$1')
.replace(/(?<![\w_])_([^_]+)_(?![\w_])/g, '$1')
.replace(/`+/g, '')
// A link reads as its label; the target is not for a preview.
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\\([\\`*_[\]#>~|+.()-])/g, '$1')
.trim()
);
}
// --- helpers -------------------------------------------------------------
function isNoteFile(name: string): boolean {

View File

@@ -20,7 +20,11 @@ import {
NoteNotFound,
filterNotes,
readNoteAt,
plainText,
readNotes,
noteSubjects,
noteSections,
searchNotes,
replaceNote,
writeNote,
} from '../core/notes.ts';
@@ -40,6 +44,24 @@ import type { Services } from '../services.ts';
* index and mirror, `/token` only to the server's own token, and every upstream
* call either triggers is a GET.
*/
/**
* The first words of a note, for a list row.
*
* Headings and list markers are dropped: a row that reads "## 1. Deutsch —
* 08:00" repeats what the row already says, and the point of the line is the
* first thing that was actually written down.
*/
function previewOf(text: string): string {
for (const line of text.split('\n')) {
// Headings are skipped rather than stripped: the row above already says
// which lessons the day holds, and repeating one is not a preview.
if (/^#{1,6}\s/.test(line.trim())) continue;
const plain = plainText(line);
if (plain) return plain.length > 120 ? `${plain.slice(0, 117)}` : plain;
}
return '';
}
const NO_NOTES_DIR =
'This server keeps no notes: NOTES_DIR is not set on it. See docs/NOTES.md.';
@@ -279,8 +301,20 @@ export function createApiRouter(services: Services): Router {
writable: services.config.notesWritable,
count: notes.length,
// The body is dropped from a listing: a term of notes is megabytes,
// and the CLI asks for the ones it wants by path.
notes: notes.slice(0, limit).map(({ text, ...rest }) => rest),
// and the CLI asks for the ones it wants by path. What replaces it
// is what a list *shows* — the lessons a day covers and a line of
// its text — so the app's note list needs one request, not one per
// note.
notes: notes.slice(0, limit).map(({ text, ...rest }) => {
const note = { ...rest, text };
const sections = noteSections(note);
return {
...rest,
subjects: noteSubjects(note),
lessons: sections.length,
preview: previewOf(note.text),
};
}),
});
} catch (error) {
if (error instanceof NoteNotFound) return res.status(404).json({ error: 'not_found', message: error.message });
@@ -288,6 +322,36 @@ export function createApiRouter(services: Services): Router {
}
});
/**
* Full text over the note files, for the app's search box.
*
* Reads disk rather than the index on purpose: notes reach the index only on
* a full crawl, so a lesson written this morning would not be findable, and
* "what did I write this week" is most of what anyone searches their own
* notes for. `search` in MCP is the other one — it spans Schulcloud and the
* class register too, at the cost of being as fresh as the last crawl.
*/
router.get('/notes/search', async (req: Request, res: Response) => {
const root = services.config.notesDir;
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });
const query = stringParam(req.query.q) ?? '';
if (query.trim().length < 2) {
return res.status(400).json({ error: 'invalid', message: 'Mindestens zwei Zeichen suchen.' });
}
try {
const notes = filterNotes(await readNotes(root), {
...pickParam('subject', req.query.subject),
...pickParam('since', req.query.since),
...pickParam('until', req.query.until),
});
const limit = Math.min(Number.parseInt(stringParam(req.query.limit) ?? '', 10) || 50, 200);
const hits = searchNotes(notes, query, limit);
return res.json({ query, count: hits.length, hits });
} catch (error) {
return fail(res, error, 'search notes');
}
});
router.post('/notes', express.json({ limit: '1mb' }), async (req: Request, res: Response) => {
const root = services.config.notesDir;
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });

View File

@@ -72,6 +72,97 @@ header { border-bottom: 1px solid var(--line); }
.view { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0.75rem; gap: 0.5rem; }
/* --- the notes screen: a list, and the note that is open ---------------- */
/*
* Two panes where there is room, one at a time where there is not. The
* breakpoint is about where a phone in landscape stops being a phone: below it
* `data-pane` on the container decides which of the two is on screen, and the
* back button is the way out of the note.
*/
.notes { flex-direction: row; gap: 0; padding: 0; }
.rail {
flex: 0 0 18rem;
min-width: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--line);
background: var(--card);
}
.rail-head { display: flex; gap: 0.4rem; padding: 0.6rem 0.6rem 0.4rem; }
.rail-head form { flex: 1; min-width: 0; }
.rail-head input {
width: 100%;
padding: 0.55rem 0.7rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--bg);
color: var(--fg);
font: inherit;
font-size: 0.9rem;
}
#today { flex: 0 0 auto; width: 2.5rem; padding: 0; font-size: 1.1rem; line-height: 1; }
#rail-status { padding: 0 0.7rem 0.3rem; }
.rail-list { flex: 1; min-height: 0; overflow-y: auto; padding: 0 0.4rem 0.6rem; }
/* A whole row is the target: on a phone the thing being tapped is the note,
not a link inside it. */
.row {
display: block;
width: 100%;
text-align: left;
padding: 0.5rem 0.6rem;
margin-bottom: 0.25rem;
border: 1px solid transparent;
border-radius: 0.5rem;
background: none;
color: var(--fg);
font: inherit;
cursor: pointer;
}
.row:hover { background: var(--bg); }
.row[aria-current="true"] {
background: var(--bg);
border-color: var(--accent);
}
.row-title { font-weight: 600; font-size: 0.95rem; }
.row-line { margin: 0.1rem 0 0; color: var(--muted); font-size: 0.85rem; line-height: 1.35; }
/* Two lines of preview and no more: a row is a glance, not a read. */
.row-line.clamp {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.row mark { background: color-mix(in srgb, var(--accent) 28%, transparent); color: inherit; border-radius: 0.15rem; }
.detail { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem; min-height: 0; }
/* Only a phone needs a way back to the list; on a wide screen it is never gone. */
.back { display: none; align-self: flex-start; padding: 0.4rem 0.7rem; font-size: 0.9rem; }
@media (max-width: 720px) {
.rail { flex: 1; border-right: 0; }
.notes[data-pane="list"] .detail { display: none; }
.notes[data-pane="note"] .rail { display: none; }
.notes[data-pane="note"] .back { display: block; }
}
.note-preview { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 0.4rem; }
.note-preview h2 { margin: 0; font-size: 1.05rem; }
.note-preview p { margin: 0; }
/* --- the day bar ------------------------------------------------------- */
.daybar { display: flex; align-items: center; gap: 0.5rem; }

View File

@@ -1,4 +1,5 @@
import { createEditor } from './editor.js';
import { markdownToHtml } from './markdown.js';
/*
* The notes app.
@@ -39,6 +40,18 @@ const ui = {
tabSettings: document.getElementById('tab-settings'),
viewNotes: document.getElementById('view-notes'),
viewSettings: document.getElementById('view-settings'),
searchForm: document.getElementById('search-form'),
searchInput: document.getElementById('search-input'),
railStatus: document.getElementById('rail-status'),
railList: document.getElementById('rail-list'),
today: document.getElementById('today'),
back: document.getElementById('back'),
daybar: document.querySelector('.daybar'),
actions: document.querySelector('.actions'),
notePreview: document.getElementById('note-preview'),
notePreviewTitle: document.getElementById('note-preview-title'),
notePreviewPath: document.getElementById('note-preview-path'),
notePreviewBody: document.getElementById('note-preview-body'),
prev: document.getElementById('prev'),
next: document.getElementById('next'),
dayTitle: document.getElementById('day-title'),
@@ -192,6 +205,18 @@ function setStatus(message, kind) {
ui.dayStatus.className = 'status' + (kind ? ' ' + kind : '');
}
/** A school day, in the editor, with the list showing which one. */
async function openDay(date) {
ui.notePreview.hidden = true;
showEditor(true);
await loadDay(date);
}
/** Where a day's note lives, which is also its id in the list. */
function dayPathFor(date) {
return date.slice(0, 4) + '/' + date + '.md';
}
async function loadDay(date) {
// Anything unsaved goes to the draft before the view moves, or switching
// days would be a way to lose a lesson.
@@ -280,6 +305,7 @@ async function loadDay(date) {
describeLessons(info);
ui.fill.hidden = !day.missing;
markOpenRow();
}
function describeLessons(info) {
@@ -319,9 +345,12 @@ async function saveDay(automatic) {
// with, and sending null would look like "I saw no version".
...(day.modifiedAt ? { expectedModifiedAt: day.modifiedAt } : {}),
});
const isNew = !day.modifiedAt;
day.saved = text;
day.modifiedAt = result.modifiedAt;
day.dirty = false;
// A day that had no note until now is not in the list yet.
if (isNew && !ui.searchInput.value.trim()) void loadRail();
day.conflicted = false;
ui.conflict.hidden = true;
clearDraft(day.date);
@@ -418,6 +447,250 @@ function addFact(term, value) {
ui.serverState.append(dt, dd);
}
// --- the note list -------------------------------------------------------
/*
* The rail: every note, newest first, with the open one marked — and the
* search box at the top of it, because searching your notes is a way of
* finding one, not a separate place to be.
*
* Searching reads the **files** on the server rather than the Postgres index.
* Notes reach that 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. The `search` tool in Claude is the other half:
* it spans Schulcloud and the class register too, at the cost of being only as
* fresh as the last crawl.
*/
const SEARCH_DEBOUNCE_MS = 350;
/** Day notes live at `2026/2026-09-04.md`; anything else opens read-only. */
const DAY_NOTE = /^\d{4}\/(\d{4}-\d{2}-\d{2})\.md$/;
let searchTimer = 0;
let searchTerms = [];
async function loadRail() {
ui.railStatus.textContent = 'Wird geladen …';
try {
const listing = await api('/api/notes?limit=400');
searchTerms = [];
showRows(listing.notes.map(noteRow));
ui.railStatus.textContent =
listing.count === 0
? 'Noch keine Notizen. öffnet den heutigen Tag.'
: listing.count + ' Notiz(en)' + (listing.notes.length < listing.count ? ', neueste 400' : '');
} catch (error) {
if (error.message === 'unauthorized') return;
ui.railList.replaceChildren();
ui.railStatus.textContent = error.status ? error.message : 'Offline — die Liste braucht den Server.';
}
}
/** One note as a row: what it is, and the first thing written in it. */
function noteRow(note) {
const lessons = note.lessons > 0 ? note.lessons + ' Stunde' + (note.lessons === 1 ? '' : 'n') : '';
const subjects = (note.subjects || []).join(' · ');
return {
path: note.path,
date: note.date,
title: note.title,
// Subjects say more than the date repeated, and the preview says more
// than either when a note is a single page of prose.
line: subjects || lessons || note.preview || '',
second: subjects && note.preview ? note.preview : '',
};
}
/** One search hit as a row: the lesson it matched in, and why. */
function hitRow(hit) {
return {
path: hit.path,
date: hit.date,
heading: hit.heading,
title: hit.heading || hit.subject || hit.title,
line: hit.date ? germanDate(hit.date) : hit.path,
second: hit.snippet,
mark: true,
};
}
function showRows(rows) {
const list = document.createDocumentFragment();
for (const row of rows) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'row';
item.dataset.path = row.path;
if (row.heading) item.dataset.heading = row.heading;
const title = document.createElement('div');
title.className = 'row-title';
if (row.mark) highlight(title, row.title);
else title.textContent = row.title;
item.append(title);
for (const [text, clamp] of [
[row.line, false],
[row.second, true],
]) {
if (!text) continue;
const line = document.createElement('p');
line.className = 'row-line' + (clamp ? ' clamp' : '');
if (row.mark) highlight(line, text);
else line.textContent = text;
item.append(line);
}
item.addEventListener('click', () => void openRow(row));
list.append(item);
}
ui.railList.replaceChildren(list);
markOpenRow();
}
/**
* The matched words marked, without building HTML from them.
*
* A snippet is the user's own text, and text that has been through a URL and a
* JSON response is exactly what should not be handed to `innerHTML`.
*/
function highlight(target, text) {
const terms = searchTerms.map(fold).filter((term) => term.length > 1);
if (terms.length === 0) {
target.textContent = text;
return;
}
const folded = fold(text);
const marks = [];
for (const term of terms) {
for (let at = folded.indexOf(term); at !== -1; at = folded.indexOf(term, at + term.length)) {
marks.push([at, at + term.length]);
}
}
marks.sort((a, b) => a[0] - b[0]);
let cursor = 0;
for (const [from, to] of marks) {
if (from < cursor) continue;
target.append(text.slice(cursor, from));
const mark = document.createElement('mark');
mark.textContent = text.slice(from, to);
target.append(mark);
cursor = to;
}
target.append(text.slice(cursor));
}
/** Lowercase without accents — the same folding the server searches with. */
function fold(value) {
return value.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
}
function germanDate(date) {
const parts = date.split('-');
return parts[2] + '.' + parts[1] + '.' + parts[0];
}
async function runSearch(query) {
window.clearTimeout(searchTimer);
const value = query.trim();
if (value.length === 0) return loadRail();
if (value.length < 2) {
ui.railStatus.textContent = 'Mindestens zwei Zeichen.';
return;
}
ui.railStatus.textContent = 'Wird gesucht …';
try {
const result = await api('/api/notes/search?q=' + encodeURIComponent(value) + '&limit=100');
searchTerms = value.split(/\s+/).filter(Boolean);
showRows(result.hits.map(hitRow));
ui.railStatus.textContent =
result.count === 0
? 'Nichts gefunden — jedes Wort muss vorkommen.'
: result.count + ' Treffer' + (result.count >= 100 ? ' (mehr vorhanden)' : '');
} catch (error) {
if (error.message === 'unauthorized') return;
ui.railList.replaceChildren();
ui.railStatus.textContent = error.status ? error.message : 'Offline — die Suche braucht den Server.';
}
}
/**
* A row, opened.
*
* A school day opens in the editor, because that is where it is written.
* Anything else — an imported note, a page of revision — has no day to open, so
* it is shown read-only rather than forced into a day-shaped screen.
*/
async function openRow(row) {
const day = DAY_NOTE.exec(row.path);
showPane('note');
if (day) {
ui.notePreview.hidden = true;
showEditor(true);
await loadDay(day[1]);
// A search hit names a lesson, so put that lesson on screen rather than
// the top of a day with six of them.
if (row.heading) scrollToHeading(row.heading);
return;
}
await showNoteReadOnly(row.path);
}
function scrollToHeading(heading) {
const wanted = fold(heading).trim();
for (const element of ui.editor.querySelectorAll('h2')) {
if (fold(element.textContent).trim() === wanted) {
element.scrollIntoView({ block: 'start' });
return;
}
}
}
async function showNoteReadOnly(path) {
showEditor(false);
ui.notePreview.hidden = false;
ui.notePreviewTitle.textContent = '…';
try {
const note = await api('/api/notes?path=' + encodeURIComponent(path));
ui.notePreviewTitle.textContent = note.title;
ui.notePreviewPath.textContent = note.path + ' — schreibgeschützt, weil diese Notiz kein Schultag ist.';
// Markdown from our own store, through the parser the editor trusts with
// the same input: it escapes everything it did not produce itself.
ui.notePreviewBody.innerHTML = markdownToHtml(note.text ?? '');
day.path = note.path;
markOpenRow();
} catch (error) {
if (error.message === 'unauthorized') return;
ui.notePreviewTitle.textContent = 'Konnte die Notiz nicht öffnen';
ui.notePreviewPath.textContent = error.message;
}
}
/** The day editor and everything that belongs to it, on or off. */
function showEditor(on) {
for (const element of [ui.daybar, ui.dayStatus, ui.toolbar, ui.actions]) element.hidden = !on;
ui.editor.hidden = !on || editor.mode !== 'rich';
ui.source.hidden = !on || editor.mode !== 'source';
// The hint belongs to whatever is loaded next; leaving it visible and empty
// would cost a line of editor for nothing.
if (!on) {
ui.conflict.hidden = true;
ui.editorHint.hidden = true;
}
}
function showPane(pane) {
ui.viewNotes.dataset.pane = pane;
}
/** Marks the row whose note is open, whichever list is showing. */
function markOpenRow() {
for (const row of ui.railList.querySelectorAll('.row')) {
row.setAttribute('aria-current', String(row.dataset.path === day.path));
}
}
// --- views ---------------------------------------------------------------
function showLogin() {
@@ -450,6 +723,8 @@ ui.loginForm.addEventListener('submit', async (event) => {
ui.password.value = '';
showApp();
await loadDay(day.date);
if (window.matchMedia('(min-width: 721px)').matches) showPane('note');
await loadRail();
} catch (error) {
ui.loginError.textContent =
error.status === 429 ? 'Zu viele Versuche. ' + error.message : 'Passwort falsch.';
@@ -465,10 +740,31 @@ ui.logout.addEventListener('click', async () => {
ui.tabNotes.addEventListener('click', () => showTab('notes'));
ui.tabSettings.addEventListener('click', () => showTab('settings'));
ui.prev.addEventListener('click', () => void loadDay(shiftDate(day.date, -1)));
ui.next.addEventListener('click', () => void loadDay(shiftDate(day.date, 1)));
ui.searchForm.addEventListener('submit', (event) => {
event.preventDefault();
void runSearch(ui.searchInput.value);
});
ui.searchInput.addEventListener('input', () => {
// As you type, but not on every keystroke: each search re-reads the notes
// directory on the server.
window.clearTimeout(searchTimer);
const value = ui.searchInput.value;
searchTimer = window.setTimeout(() => void runSearch(value), SEARCH_DEBOUNCE_MS);
});
ui.today.addEventListener('click', () => {
// The day you are in, whether or not it has a note yet — the one thing the
// list cannot show, because an empty day is not a note.
void openRow({ path: dayPathFor(today()) });
});
ui.back.addEventListener('click', () => showPane('list'));
ui.prev.addEventListener('click', () => void openDay(shiftDate(day.date, -1)));
ui.next.addEventListener('click', () => void openDay(shiftDate(day.date, 1)));
ui.dayDate.addEventListener('change', () => {
if (ui.dayDate.value) void loadDay(ui.dayDate.value);
if (ui.dayDate.value) void openDay(ui.dayDate.value);
});
ui.save.addEventListener('click', () => void saveDay(false));
@@ -531,4 +827,8 @@ void (async () => {
}
showApp();
await loadDay(day.date);
// Wide enough for both panes: the day is already open beside the list.
// Narrow: the list comes first, the way a notes app opens.
if (window.matchMedia('(min-width: 721px)').matches) showPane('note');
await loadRail();
})();

View File

@@ -346,10 +346,18 @@ export function createEditor(options) {
rich.addEventListener('paste', (event) => {
if (!enabled || mode !== 'rich') return;
const data = event.clipboardData;
if (!data) return;
const html = data.getData('text/html');
const text = data.getData('text/plain');
if (!html && !text) return;
const html = data ? data.getData('text/html') : '';
const text = data ? data.getData('text/plain') : '';
if (!html && !text) {
// Nothing readable in the event — either the paste really is empty, or
// this engine withholds the clipboard. Let it happen and tidy after,
// because raw pasted markup sitting in the document is what the
// toolbar cannot format and the serializer should never have to meet.
// Only if something actually arrived: an empty paste must not move the
// caret or mark the note changed.
tidyIfChanged();
return;
}
event.preventDefault();
let markdown;
@@ -366,6 +374,28 @@ export function createEditor(options) {
notify();
});
/** Tidies the document after a paste this editor could not read. */
function tidyIfChanged() {
const before = rich.innerHTML;
window.setTimeout(() => {
if (rich.innerHTML !== before) normalise();
}, 0);
}
/**
* The document, reduced to what this editor models.
*
* Everything the round trip does not understand is dropped here rather than
* being carried around until a save, and the caret is placed at the end
* because there is no way to keep it across a rebuild.
*/
function normalise() {
rich.innerHTML = markdownToHtml(markdownFromDom(rich));
ensureTrailingParagraph();
if (rich.lastElementChild) placeCaret(rich.lastElementChild);
notify();
}
rich.addEventListener('keydown', (event) => {
const modifier = event.metaKey || event.ctrlKey;
if (modifier && !event.altKey) {

View File

@@ -32,7 +32,24 @@
</header>
<!-- Notes: one school day per note, one heading per lesson. -->
<main id="view-notes" class="view">
<!-- Two panes: the notes on the left, the open one on the right. Side by
side where there is room; one at a time on a phone, where `data-pane`
says which, and the back button returns to the list. -->
<main id="view-notes" class="view notes" data-pane="list">
<aside class="rail">
<div class="rail-head">
<form id="search-form" class="searchbar" role="search">
<input id="search-input" type="search" inputmode="search" autocomplete="off"
placeholder="Durchsuchen" aria-label="Notizen durchsuchen">
</form>
<button type="button" id="today" aria-label="Heutiger Tag" title="Heutiger Tag"></button>
</div>
<p id="rail-status" class="status" role="status" aria-live="polite"></p>
<div id="rail-list" class="rail-list"></div>
</aside>
<section class="detail">
<button type="button" id="back" class="back"> Notizen</button>
<div class="daybar">
<button type="button" id="prev" aria-label="Vorheriger Tag"></button>
<div class="daybar-centre">
@@ -83,6 +100,15 @@
<button type="button" id="fill" hidden>Stunden ergänzen</button>
<span id="lessons-hint" class="hint"></span>
</div>
<!-- A note that is not a school day — one from the import, a page of
revision — has no day to open, so it is shown rather than edited. -->
<article id="note-preview" class="note-preview" hidden>
<h2 id="note-preview-title"></h2>
<p id="note-preview-path" class="hint"></p>
<div id="note-preview-body" class="editor" aria-readonly="true"></div>
</article>
</section>
</main>
<!-- Settings: the Schulcloud token, and what the server is doing. -->

View File

@@ -384,7 +384,13 @@ function serializeBlocks(node) {
};
for (const child of children(node)) {
if (child.nodeType === 1 && BLOCK_TAGS.has(child.nodeName)) {
// A block *inside* an inline element is still a block. WebKit wraps a
// copied selection in one span carrying the computed style of everything
// in it, so a paste from Apple Notes arrives as
// `<span style="font-weight: 700"><div>…</div><div>…</div></span>` —
// and reading that span as inline flattened a whole note into one
// paragraph and made every word of it bold.
if (child.nodeType === 1 && (BLOCK_TAGS.has(child.nodeName) || holdsBlock(child))) {
flush();
const block = serializeBlock(child);
if (block) out.push(block);
@@ -438,7 +444,10 @@ function serializeBlock(element) {
// asking what is inside.
return hasBlockChild(element) ? serializeBlocks(element) : paragraph(inlineFrom(children(element)));
default:
return paragraph(inlineFrom(children(element)));
// Anything else that reached this function is here because it holds
// blocks — a paste wrapper, most often. Its own tag means nothing;
// what it contains means everything.
return holdsBlock(element) ? serializeBlocks(element) : paragraph(inlineFrom(children(element)));
}
}
@@ -572,9 +581,15 @@ function inlineNode(node) {
return emphasise(inlineFrom(children(node)), '~~');
case 'SPAN':
case 'FONT': {
// What a paste leaves behind. The tag says nothing; the style might.
const style = node.getAttribute('style') ?? '';
// What a paste leaves behind. The tag says nothing; the style might
// — but only for a span wrapping one run of text. A span with
// elements inside it is a container carrying inherited style, not
// emphasis: WebKit hangs the whole computed style of a copied
// selection on such a wrapper, and honouring its `font-weight: 700`
// is what made an entire pasted note bold.
const inner = inlineFrom(children(node));
if (!isTextOnly(node)) return inner;
const style = node.getAttribute('style') ?? '';
if (/font-weight:\s*(bold|[6-9]00)/i.test(style)) return emphasise(inner, '**');
if (/font-style:\s*italic/i.test(style)) return emphasise(inner, '_');
return inner;
@@ -647,6 +662,26 @@ function hasBlockChild(element) {
return children(element).some((child) => child.nodeType === 1 && BLOCK_TAGS.has(child.nodeName));
}
/**
* Whether a block hides anywhere under this element.
*
* Pastes nest wrappers several deep — `<span><span><div>` — so the answer has
* to be looked for rather than checked one level down. Bounded, because the
* tree comes from a clipboard and nothing here should be able to hang on one.
*/
function holdsBlock(element, depth = 0) {
if (depth > 6) return false;
return children(element).some(
(child) =>
child.nodeType === 1 && (BLOCK_TAGS.has(child.nodeName) || child.nodeName === 'LI' || holdsBlock(child, depth + 1)),
);
}
/** A span with nothing but text in it — the only shape whose style is emphasis. */
function isTextOnly(element) {
return children(element).every((child) => child.nodeType === 3 || child.nodeName === 'BR');
}
function children(node) {
return Array.prototype.slice.call(node.childNodes ?? []);
}

View File

@@ -240,3 +240,46 @@ test('a row the editor appended round-trips', () => {
'<tbody><tr><td>1</td><td>2</td></tr><tr><td><br></td><td><br></td></tr></tbody></table>';
assert.equal(markdownFromDom(parseHtml(html)), '| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |');
});
test('a paste from Apple Notes keeps its structure and is not all bold', () => {
// What WebKit actually puts on the clipboard: one wrapper span carrying the
// *computed* style of everything copied — including `font-weight: 700` —
// with the real blocks nested inside it. Read naively that makes the whole
// note bold and flattens every line into one paragraph.
const html =
'<meta charset="UTF-8"><span style="color: rgb(0, 0, 0); font-family: Helvetica; ' +
'font-size: 16px; font-weight: 700; text-align: start; -webkit-text-stroke-width: 0px; ' +
'display: inline !important; float: none;">' +
'<div><b>Erörterung</b></div><div><br></div><div>These, Argument, Fazit</div>' +
'<ul><li>Gegenargument nicht vergessen</li><li>Fazit knapp halten</li></ul></span>';
assert.equal(
markdownFromDom(parseHtml(html)),
'**Erörterung**\n\nThese, Argument, Fazit\n\n- Gegenargument nicht vergessen\n- Fazit knapp halten',
);
});
test('a container\'s font never swallows the blocks inside it', () => {
// The general rule behind the case above: an element holding blocks is a
// container whatever its tag, and a container's style is not emphasis.
const html = '<span style="font-weight: bold"><h2>Deutsch</h2><p>Text</p></span>';
assert.equal(markdownFromDom(parseHtml(html)), '## Deutsch\n\nText');
});
test('a styled span around a single run is still emphasis', () => {
// The case the style check exists for, which must keep working.
assert.equal(markdownFromDom(parseHtml('<p><span style="font-weight: 700">fett</span> rest</p>')), '**fett** rest');
assert.equal(markdownFromDom(parseHtml('<p><span style="font-style: italic">kursiv</span></p>')), '_kursiv_');
});
test('no word is ever lost, whatever the markup', () => {
// The property that matters more than any particular shape: a note is
// allowed to lose its formatting, never its words.
const html =
'<div><span style="font-weight:700"><div>Erste Zeile</div>' +
'<blockquote><span><p>Zitat</p></span></blockquote>' +
'<table><tr><td><div>Zelle</div></td></tr></table></span></div>';
const markdown = markdownFromDom(parseHtml(html));
for (const word of ['Erste', 'Zeile', 'Zitat', 'Zelle']) {
assert.ok(markdown.includes(word), `lost "${word}" in: ${markdown}`);
}
});

View File

@@ -14,9 +14,11 @@ import {
subjectFromHeading,
notePathFor,
parseNote,
plainText,
readNoteAt,
readNotes,
renderNote,
searchNotes,
splitFrontmatter,
writeNote,
} from '../src/core/notes.ts';
@@ -361,3 +363,130 @@ describe('filterNotes', () => {
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);
});
});
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');
});
});