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>
This commit is contained in:
MechaCat02
2026-09-21 18:09:15 +02:00
parent f545b7cf54
commit e129fd4b0a
13 changed files with 702 additions and 22 deletions

View File

@@ -550,6 +550,123 @@ 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(plainLine)
.join(' ')
.replace(/\s{2,}/g, ' ')
.trim();
return preview.length > 240 ? `${preview.slice(0, 237)}` : preview;
}
/**
* One line of Markdown as the words it holds.
*
* A snippet is read, not parsed: `| 1NF | atomare Werte |` says more as
* "1NF · atomare Werte", and a row of `**` in a preview is noise.
*/
function plainLine(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(' · ');
}
return value
.replace(/^#{1,6}\s+/, '')
.replace(/^>\s?/, '')
.replace(/^[-*+]\s+(\[[ xX]\]\s+)?/, '')
.replace(/^\d{1,9}[.)]\s+/, '')
.replace(/(\*\*|__|~~)/g, '')
.replace(/`+/g, '')
.trim();
}
// --- helpers -------------------------------------------------------------
function isNoteFile(name: string): boolean {

View File

@@ -21,6 +21,7 @@ import {
filterNotes,
readNoteAt,
readNotes,
searchNotes,
replaceNote,
writeNote,
} from '../core/notes.ts';
@@ -288,6 +289,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

@@ -271,6 +271,51 @@ button:disabled { opacity: 0.5; cursor: default; }
font-size: 0.9rem;
}
/* --- search ------------------------------------------------------------ */
.searchbar { display: flex; gap: 0.5rem; }
.searchbar input {
flex: 1;
min-width: 0;
padding: 0.7rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--bg);
color: var(--fg);
font: inherit;
}
.searchbar button { background: var(--accent); border-color: var(--accent); color: #ffffff; font-weight: 600; }
.results { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 0.5rem; }
/* A whole result is the target: on a phone the thing being tapped is a card,
not a link inside one. */
.hit {
width: 100%;
text-align: left;
padding: 0.6rem 0.75rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--card);
color: var(--fg);
font: inherit;
cursor: pointer;
display: block;
}
.hit-head { display: flex; gap: 0.5rem; align-items: baseline; flex-wrap: wrap; }
.hit-subject { font-weight: 600; }
.hit-date { color: var(--muted); font-size: 0.85rem; }
.hit-snippet { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.9rem; line-height: 1.4; }
.hit mark { background: color-mix(in srgb, var(--accent) 25%, transparent); color: inherit; border-radius: 0.15rem; }
.note-preview { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 0.5rem; }
.note-preview h2 { margin: 0; font-size: 1.05rem; }
.note-preview .editor { cursor: default; }
#search-back { align-self: flex-start; }
/* --- cards (login, settings) ------------------------------------------- */
.card {

View File

@@ -1,4 +1,5 @@
import { createEditor } from './editor.js';
import { markdownToHtml } from './markdown.js';
/*
* The notes app.
@@ -36,9 +37,19 @@ const ui = {
loginError: document.getElementById('login-error'),
app: document.getElementById('app'),
tabNotes: document.getElementById('tab-notes'),
tabSearch: document.getElementById('tab-search'),
tabSettings: document.getElementById('tab-settings'),
viewNotes: document.getElementById('view-notes'),
viewSearch: document.getElementById('view-search'),
viewSettings: document.getElementById('view-settings'),
searchForm: document.getElementById('search-form'),
searchInput: document.getElementById('search-input'),
searchStatus: document.getElementById('search-status'),
searchResults: document.getElementById('search-results'),
searchNote: document.getElementById('search-note'),
searchNoteTitle: document.getElementById('search-note-title'),
searchNoteBody: document.getElementById('search-note-body'),
searchBack: document.getElementById('search-back'),
prev: document.getElementById('prev'),
next: document.getElementById('next'),
dayTitle: document.getElementById('day-title'),
@@ -418,6 +429,150 @@ function addFact(term, value) {
ui.serverState.append(dt, dd);
}
// --- search --------------------------------------------------------------
/*
* The notes themselves, read from disk by the server rather than from the
* index. A lesson written this morning is findable this morning, which is most
* of what anyone searches their own notes for — the MCP `search` tool is the
* other half, spanning Schulcloud and the class register 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 runSearch(query) {
window.clearTimeout(searchTimer);
const value = query.trim();
ui.searchNote.hidden = true;
if (value.length < 2) {
ui.searchResults.replaceChildren();
ui.searchStatus.textContent = value ? 'Mindestens zwei Zeichen.' : '';
return;
}
ui.searchStatus.textContent = 'Wird gesucht …';
try {
const result = await api('/api/notes/search?q=' + encodeURIComponent(value) + '&limit=60');
searchTerms = value.split(/\s+/).filter(Boolean);
showHits(result.hits);
ui.searchStatus.textContent =
result.count === 0
? 'Nichts gefunden. Die Suche braucht jedes Wort — und kennt keine Wortformen.'
: result.count + ' Treffer' + (result.count >= 60 ? ' (mehr vorhanden)' : '') + '.';
} catch (error) {
if (error.message === 'unauthorized') return;
ui.searchResults.replaceChildren();
ui.searchStatus.textContent = error.status ? error.message : 'Offline — die Suche braucht den Server.';
}
}
function showHits(hits) {
const list = document.createDocumentFragment();
for (const hit of hits) {
const card = document.createElement('button');
card.type = 'button';
card.className = 'hit';
const head = document.createElement('div');
head.className = 'hit-head';
const subject = document.createElement('span');
subject.className = 'hit-subject';
// The lesson if there is one, else the note — never just "Freitag".
subject.textContent = hit.heading || hit.subject || hit.title;
const when = document.createElement('span');
when.className = 'hit-date';
when.textContent = hit.date ? germanDate(hit.date) : hit.path;
head.append(subject, when);
const snippet = document.createElement('p');
snippet.className = 'hit-snippet';
highlight(snippet, hit.snippet);
card.append(head, snippet);
card.addEventListener('click', () => openHit(hit));
list.append(card);
}
ui.searchResults.replaceChildren(list);
}
/**
* The matched words marked, without building HTML from them.
*
* A snippet is the user's own text, but it reaches here through a URL and a
* JSON response, and `innerHTML` on anything that has been round-tripped is
* how an editor ends up rendering what it should be showing.
*/
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 [start, end] of marks) {
if (start < cursor) continue;
target.append(text.slice(cursor, start));
const mark = document.createElement('mark');
mark.textContent = text.slice(start, end);
target.append(mark);
cursor = end;
}
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];
}
/**
* A result, opened.
*
* A day note 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 openHit(hit) {
const day = DAY_NOTE.exec(hit.path);
if (day) {
showTab('notes');
await loadDay(day[1]);
return;
}
ui.searchStatus.textContent = 'Wird geöffnet …';
try {
const note = await api('/api/notes?path=' + encodeURIComponent(hit.path));
ui.searchNoteTitle.textContent = note.title;
// The note is Markdown from our own store, and markdownToHtml escapes
// everything it did not produce itself — the same parser the editor
// trusts with the same input.
ui.searchNoteBody.innerHTML = markdownToHtml(note.text ?? '');
ui.searchNote.hidden = false;
ui.searchStatus.textContent = note.path;
} catch (error) {
if (error.message === 'unauthorized') return;
ui.searchStatus.textContent = 'Konnte die Notiz nicht öffnen: ' + error.message;
}
}
// --- views ---------------------------------------------------------------
function showLogin() {
@@ -432,12 +587,16 @@ function showApp() {
}
function showTab(name) {
const notes = name !== 'settings';
ui.viewNotes.hidden = !notes;
ui.viewSettings.hidden = notes;
ui.tabNotes.setAttribute('aria-current', notes ? 'page' : 'false');
ui.tabSettings.setAttribute('aria-current', notes ? 'false' : 'page');
if (!notes) void loadSettings();
for (const [tab, view, id] of [
[ui.tabNotes, ui.viewNotes, 'notes'],
[ui.tabSearch, ui.viewSearch, 'search'],
[ui.tabSettings, ui.viewSettings, 'settings'],
]) {
view.hidden = name !== id;
tab.setAttribute('aria-current', name === id ? 'page' : 'false');
}
if (name === 'settings') void loadSettings();
if (name === 'search') ui.searchInput.focus();
}
// --- wiring --------------------------------------------------------------
@@ -463,8 +622,26 @@ ui.logout.addEventListener('click', async () => {
});
ui.tabNotes.addEventListener('click', () => showTab('notes'));
ui.tabSearch.addEventListener('click', () => showTab('search'));
ui.tabSettings.addEventListener('click', () => showTab('settings'));
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.searchBack.addEventListener('click', () => {
ui.searchNote.hidden = true;
});
ui.prev.addEventListener('click', () => void loadDay(shiftDate(day.date, -1)));
ui.next.addEventListener('click', () => void loadDay(shiftDate(day.date, 1)));
ui.dayDate.addEventListener('change', () => {

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

@@ -27,6 +27,7 @@
<header>
<nav class="tabs">
<button type="button" id="tab-notes" class="tab" aria-current="page">Notizen</button>
<button type="button" id="tab-search" class="tab">Suche</button>
<button type="button" id="tab-settings" class="tab">Einstellungen</button>
</nav>
</header>
@@ -85,6 +86,23 @@
</div>
</main>
<!-- Search: the notes themselves, read from disk rather than the index, so a
lesson written this morning is findable this morning. -->
<main id="view-search" class="view" hidden>
<form id="search-form" class="searchbar">
<input id="search-input" type="search" inputmode="search" autocomplete="off"
placeholder="In den eigenen Notizen suchen" aria-label="Suchbegriff">
<button type="submit">Suchen</button>
</form>
<p id="search-status" class="status" role="status" aria-live="polite"></p>
<div id="search-results" class="results"></div>
<article id="search-note" class="note-preview" hidden>
<button type="button" id="search-back"> Zurück zu den Treffern</button>
<h2 id="search-note-title"></h2>
<div id="search-note-body" class="editor" aria-readonly="true"></div>
</article>
</main>
<!-- Settings: the Schulcloud token, and what the server is doing. -->
<main id="view-settings" class="view" hidden>
<section class="card">

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 ?? []);
}