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:
@@ -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}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
readNoteAt,
|
||||
readNotes,
|
||||
renderNote,
|
||||
searchNotes,
|
||||
splitFrontmatter,
|
||||
writeNote,
|
||||
} from '../src/core/notes.ts';
|
||||
@@ -361,3 +362,107 @@ 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:00–08:45',
|
||||
'',
|
||||
'Normalisierung: erste, zweite und dritte Normalform.',
|
||||
'',
|
||||
'## 2. Deutsch — 08:50–09: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:00–08: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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user