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 {