Put the interface into English, and give the notebook one way in

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>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-11 02:17:22 +02:00
1 parent 0a7cf26b28
commit 52bd2b8f3d
27 files changed
+469 -336

No files matched your search

+28 -32
View File
@@ -15,7 +15,9 @@ import {
downloads,
lastDownloadName,
launch,
openAddMenu,
report,
search,
withFreshUser,
} from "./lib.mjs";
@@ -115,14 +117,15 @@ try {
/* ── import ── */
const chooser = p.waitForEvent("filechooser");
await p.getByTitle("Import PDF").click();
await openAddMenu(p);
await p.locator('[data-add-option="Upload PDF"]').click();
await (await chooser).setFiles(PDF_PATH);
await p.waitForTimeout(4000);
check((await p.locator("[data-pdf-page]").count()) > 0, "the document is shown after importing");
const pdfNoteId = (await api(p, "GET", "/notes")).body[0].id;
check(
(await p.locator("[data-pdf-position]").innerText()).includes("/ 2"),
(await p.locator("[data-pdf-position]").innerText()).includes("of 2"),
"both pages are counted",
await p.locator("[data-pdf-position]").innerText()
);
@@ -188,18 +191,15 @@ try {
/* ── the document is findable ── */
await p.waitForTimeout(2500); // extraction runs after the first page is shown
await p.getByPlaceholder("Search notes...").fill("Zebrastreifen");
await p.waitForTimeout(1500);
await search(p, "Zebrastreifen");
check(
(await p.locator("[data-note-row]").count()) === 1,
"searching a word from inside the PDF finds the note",
`${await p.locator("[data-note-row]").count()} rows`
);
await p.getByPlaceholder("Search notes...").fill("Wortdasnichtvorkommt");
await p.waitForTimeout(1200);
await search(p, "Wortdasnichtvorkommt");
check((await p.locator("[data-note-row]").count()) === 0, "and a word that is not in it finds nothing");
await p.getByPlaceholder("Search notes...").fill("");
await p.waitForTimeout(1000);
await search(p, "");
await p.locator("[data-note-row]").first().click();
await p.waitForTimeout(3000);
@@ -262,12 +262,12 @@ try {
}, n);
const before = await inkBounds(1);
await p.getByTitle("Größer").click();
await p.getByTitle("Zoom in").click();
await p.waitForTimeout(2500);
const after = await inkBounds(1);
const drift = Math.max(Math.abs(before.x - after.x), Math.abs(before.y - after.y), Math.abs(before.w - after.w));
check(drift < 0.02, "the stroke stays on the same spot when zoomed", `drift ${drift.toFixed(3)}`);
await p.getByTitle("Kleiner").click();
await p.getByTitle("Zoom out").click();
await p.waitForTimeout(1500);
/* ── hiding it ── */
@@ -325,11 +325,9 @@ try {
await p.screenshot({ path: "/out/pdf-with-notes.png" });
/* And they are searchable, alongside the document's own text. */
await p.getByPlaceholder("Search notes...").fill("Randbemerkung");
await p.waitForTimeout(1500);
await search(p, "Randbemerkung");
check((await p.locator("[data-note-row]").count()) === 1, "notes written beside a PDF are searchable");
await p.getByPlaceholder("Search notes...").fill("");
await p.waitForTimeout(1000);
await search(p, "");
await p.locator("[data-note-row]").first().click();
await p.waitForTimeout(3000);
@@ -345,14 +343,14 @@ try {
await openExport();
check(
(await p.getByText("Was soll heraus?").count()) > 0,
(await p.getByText("What should come out?").count()) > 0,
"a PDF note is asked what should come out, not offered note formats"
);
await p.screenshot({ path: "/out/pdf-export-wizard.png" });
/* The untouched original. */
await clearDownloads(p);
await p.getByRole("button", { name: /PDF im Original/ }).click();
await p.getByRole("button", { name: /The original PDF/ }).click();
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
await p.waitForTimeout(900);
let files = await downloads(p);
@@ -369,7 +367,7 @@ try {
/* The same document with the strokes written in. */
await openExport();
await clearDownloads(p);
await p.getByRole("button", { name: /PDF mit deinen Anmerkungen/ }).click();
await p.getByRole("button", { name: /PDF with your annotations/ }).click();
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
await p.waitForTimeout(1500);
files = await downloads(p);
@@ -378,7 +376,7 @@ try {
check(stamped.subarray(0, 5).toString() === "%PDF-", "the annotated file is a PDF", stamped.subarray(0, 8).toString());
check(stamped.length > originalBytes.length, "and carries more than the original did", `${stamped.length} vs ${originalBytes.length}`);
check(!stamped.equals(originalBytes), "so it is not simply a copy");
check(/mit Notizen/.test(await lastDownloadName(p)), "named so the two cannot be confused", await lastDownloadName(p));
check(/annotated/.test(await lastDownloadName(p)), "named so the two cannot be confused", await lastDownloadName(p));
/* The stored file must still be the original — drawing on it changes nothing
on disk, or a clean copy could never be had again. */
@@ -502,7 +500,7 @@ try {
check(levels.cardLeft >= levels.pageRight - 2, "and beside the page rather than over it");
await field.fill("Drehmoment beachten");
await p.getByRole("button", { name: "Fertig" }).click();
await p.getByRole("button", { name: "Done" }).click();
await p.waitForTimeout(2500);
/* It has to be on the passage, not near it — so the position is checked
@@ -520,7 +518,7 @@ try {
/* Stored in PDF points, so zooming must not move it off its passage — the same
property the strokes have. */
await p.getByTitle("Größer").click();
await p.getByTitle("Zoom in").click();
await p.waitForTimeout(2000);
const zoomed = await p.evaluate(() => {
const pin = document.querySelector("[data-pin]").getBoundingClientRect();
@@ -532,20 +530,18 @@ try {
"and stays there when the document is zoomed",
`${zoomed.x.toFixed(2)} / ${zoomed.y.toFixed(2)}`
);
await p.getByTitle("Kleiner").click();
await p.getByTitle("Zoom out").click();
await p.waitForTimeout(1200);
/* Searchable: it lives in the page overlay, not in the note body, so nothing
else would ever index it. */
await p.getByPlaceholder("Search notes...").fill("Drehmoment");
await p.waitForTimeout(1500);
await search(p, "Drehmoment");
check(
(await p.locator("[data-note-row]").count()) === 1,
"a pinned note can be searched for",
String(await p.locator("[data-note-row]").count())
);
await p.getByPlaceholder("Search notes...").fill("");
await p.waitForTimeout(1000);
await search(p, "");
/* It belongs to the note, so it has to come back from the server. */
await p.reload({ waitUntil: "networkidle" });
@@ -575,7 +571,7 @@ try {
await p.waitForTimeout(400);
await p.locator("[data-pdf-mode]").click();
await p.waitForTimeout(500);
await p.getByTitle("Seite leeren").click();
await p.getByTitle("Clear page").click();
await p.waitForTimeout(2000);
check((await p.locator("[data-pin]").count()) === 1, "emptying the page's ink leaves the pinned note");
await p.locator("[data-pdf-mode]").click();
@@ -595,7 +591,7 @@ try {
await p.mouse.click(page1b.x + page1b.width * 0.75, page1b.y + page1b.height * 0.7);
await p.waitForTimeout(700);
await p.getByLabel("Pinned note").fill("Randbemerkung Zebrastreifen");
await p.getByRole("button", { name: "Fertig" }).click();
await p.getByRole("button", { name: "Done" }).click();
await p.waitForTimeout(2500);
/* The server keeps its own judgement of "is this overlay empty". It counted
@@ -618,7 +614,7 @@ try {
await p.getByTitle("Export").click();
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
await p.waitForTimeout(800);
await p.getByRole("button", { name: /PDF mit deinen Anmerkungen/ }).click();
await p.getByRole("button", { name: /PDF with your annotations/ }).click();
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
await p.waitForTimeout(1500);
const withPins = Buffer.from((await downloads(p))[0].base64, "base64");
@@ -643,7 +639,7 @@ try {
await p.mouse.click(pageL.x + pageL.width * 0.35, pageL.y + pageL.height * 0.2);
await p.waitForTimeout(800);
await p.getByLabel("Pinned note").fill("Zebrastreifen nachschlagen");
await p.getByRole("button", { name: "Fertig" }).click();
await p.getByRole("button", { name: "Done" }).click();
await p.waitForTimeout(2500);
await p.locator("[data-pdf-toggle-notes]").click();
@@ -671,7 +667,7 @@ try {
await linkEl.getAttribute("data-kind")
);
check(
(await linkEl.innerText()).includes("S. 1"),
(await linkEl.innerText()).includes("p. 1"),
"labelled with the page it points at",
await linkEl.innerText()
);
@@ -788,7 +784,7 @@ try {
await p.mouse.click(pageM.x + pageM.width * 0.5, pageM.y + pageM.height * 0.4);
await p.waitForTimeout(800);
await p.getByLabel("Pinned note").fill("Am Rand notiert");
await p.getByRole("button", { name: "Fertig" }).click();
await p.getByRole("button", { name: "Done" }).click();
await p.waitForTimeout(2500);
const wideMargins = await p.locator("[data-pdf-margin]").count();
@@ -865,7 +861,7 @@ try {
`top ${Math.round(sheet.y)} of 820`
);
await p.getByRole("button", { name: "Fertig" }).click();
await p.getByRole("button", { name: "Done" }).click();
await p.waitForTimeout(800);
check((await p.locator("[data-pin-sheet]").count()) === 0, "and closes again");