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

@@ -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', () => {