The interface had drifted into two languages — the frame in English, everything added later in German — which is worse than either, since the same idea appeared under two names depending on when that part was written. Sorting by title still uses the German collation, so Übung 2 comes before Übung 10: that is about the notes, not the chrome. Two archive folders were renamed with it (bilder/ → images/, zeichnungen/ → drawings/). The importer reads both spellings, and both the old and new marker line for a drawing — archives exported before this are on disk, and an import that quietly dropped their drawings would be the worst kind of tidy-up. Making a note, uploading a PDF and importing Markdown were three buttons side by side, two of them icons you had to hover to identify. They are the same act — putting something new in this notebook — so they are now one + menu. The search box moves out of the sidebar and into the note list, labelled with the notebook it narrows. Nothing changed about what it does: it has always searched only the notebook you are in, but from the sidebar it read as searching everything. A separator no longer carries a sentence explaining the order the sort menu already names. Renaming joins removing on the right-click menu — a left-click on the label started an edit, so the row could not be touched without falling into one. 218 unit tests, 238 browser checks across eleven suites. Two of those caught real breakage from this change rather than needing a translation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
115 lines
4.5 KiB
JavaScript
115 lines
4.5 KiB
JavaScript
/* Full-text search over locally held bodies, and image attachments.
|
|
* Search runs in the client because the server cannot read encrypted notes, so
|
|
* the interesting part is whether the backfill fills the index at all. */
|
|
import {
|
|
OUT,
|
|
check,
|
|
createNote,
|
|
createNotebook,
|
|
launch,
|
|
report,
|
|
search,
|
|
setTitle,
|
|
typeInEditor,
|
|
withFreshUser,
|
|
} from "./lib.mjs";
|
|
|
|
/** A 3x3 red PNG, small enough to paste inline. */
|
|
const PNG =
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAYAAABWKLW/AAAAFElEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC";
|
|
|
|
/* Rows in the note list carry data-note-row; matching on the emoji instead would
|
|
* also pick up the sidebar's Trash entry, which wears the same one. */
|
|
const titles = (p) =>
|
|
p.$$eval("[data-note-row]", (els) =>
|
|
// The button holds emoji, title and date on separate lines.
|
|
els.map((e) => e.innerText.split("\n").filter(Boolean)[1]).filter(Boolean)
|
|
);
|
|
|
|
const browser = await launch();
|
|
let session;
|
|
try {
|
|
session = await withFreshUser(browser, "search");
|
|
const p = session.page;
|
|
await createNotebook(p, "Such Buch");
|
|
|
|
const seed = [
|
|
["Notiz A", "Der Kühlschrank braucht eine neue Dichtung."],
|
|
["Notiz B", "Steuererklärung abgeben, Belege liegen im Ordner."],
|
|
["Notiz C", "Ein völlig eigenständiges Stichwort: Zebrastreifen."],
|
|
["Dichtung", "Hier steht das Wort nur im Titel."],
|
|
];
|
|
for (const [title, body] of seed) {
|
|
await createNote(p);
|
|
await setTitle(p, title);
|
|
await typeInEditor(p, body);
|
|
}
|
|
console.log(`seeded ${seed.length} notes`);
|
|
// Give the background backfill time to index the bodies.
|
|
await p.waitForTimeout(3000);
|
|
|
|
/** Types into the note list's search box and reports what is left showing. */
|
|
const find = async (q) => {
|
|
await search(p, q);
|
|
await p.waitForTimeout(1200);
|
|
return titles(p);
|
|
};
|
|
|
|
check(JSON.stringify(await find("Zebrastreifen")) === '["Notiz C"]', "a word only in the body finds its note", JSON.stringify(await titles(p)));
|
|
const hits = await find("Dichtung");
|
|
check(hits.length === 2, "a word in one title and another body finds both", JSON.stringify(hits));
|
|
check(hits[0] === "Dichtung", "the title match ranks first", JSON.stringify(hits));
|
|
check(JSON.stringify(await find("Belege Ordner")) === '["Notiz B"]', "multiple terms are an AND");
|
|
check((await find("Belege Kühlschrank")).length === 0, "terms from different notes match nothing");
|
|
check(JSON.stringify(await find("kuhlschrank")) === '["Notiz A"]', "search ignores case and umlauts");
|
|
await p.screenshot({ path: `${OUT}/search-results.png` });
|
|
|
|
await search(p, "");
|
|
check((await titles(p)).length === seed.length, "clearing the box restores the full list");
|
|
|
|
/* ── attachments ── */
|
|
await p.locator("button").filter({ hasText: "Notiz A" }).first().click();
|
|
await p.waitForSelector(".ProseMirror", { timeout: 15000 });
|
|
await p.waitForTimeout(1200);
|
|
await p.locator(".ProseMirror").click();
|
|
await p.keyboard.press("Control+End");
|
|
|
|
await p.evaluate(async (b64) => {
|
|
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
const file = new File([bytes], "dot.png", { type: "image/png" });
|
|
const dt = new DataTransfer();
|
|
dt.items.add(file);
|
|
document
|
|
.querySelector(".ProseMirror")
|
|
.dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true }));
|
|
}, PNG);
|
|
await p.waitForTimeout(3500);
|
|
|
|
const img = p.locator(".ProseMirror img").first();
|
|
check((await p.locator(".ProseMirror img").count()) > 0, "the pasted image was inserted");
|
|
const src = (await img.getAttribute("src")) || "";
|
|
check(src.startsWith("blob:"), "the image renders from a blob URL, not inline bytes", src.slice(0, 24));
|
|
check(!src.startsWith("data:"), "no base64 payload sits in the document");
|
|
await p.screenshot({ path: `${OUT}/attachment.png` });
|
|
|
|
await p.reload({ waitUntil: "networkidle" });
|
|
await p.waitForTimeout(2500);
|
|
await p.locator("button").filter({ hasText: "Notiz A" }).first().click();
|
|
await p.waitForSelector(".ProseMirror", { timeout: 15000 });
|
|
await p.waitForTimeout(2500);
|
|
check((await p.locator(".ProseMirror img").count()) > 0, "the image is still there after a reload");
|
|
const loaded = await p
|
|
.locator(".ProseMirror img")
|
|
.first()
|
|
.evaluate((e) => e.complete && e.naturalWidth > 0)
|
|
.catch(() => false);
|
|
check(loaded, "the image actually loaded its pixels after the reload");
|
|
} catch (e) {
|
|
check(false, "suite ran to the end", e.message);
|
|
} finally {
|
|
await session?.cleanup();
|
|
const code = report();
|
|
await browser.close();
|
|
process.exit(code);
|
|
}
|