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:
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ?? []);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user