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>
237 lines
9.3 KiB
JavaScript
237 lines
9.3 KiB
JavaScript
/* Export to Markdown and ZIP.
|
|
*
|
|
* The unit tests cover the serialiser; this checks the part they cannot — that
|
|
* a click produces a real download whose bytes are what they should be. The
|
|
* files are caught in the browser rather than written to disk, so the archive
|
|
* can be unpacked and read here. */
|
|
import {
|
|
OUT,
|
|
captureDownloads,
|
|
check,
|
|
clearDownloads,
|
|
createNote,
|
|
createNotebook,
|
|
downloads,
|
|
insertTable,
|
|
lastDownloadName,
|
|
launch,
|
|
report,
|
|
setTitle,
|
|
typeInEditor,
|
|
withFreshUser,
|
|
} from "./lib.mjs";
|
|
|
|
const PNG =
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAYAAABWKLW/AAAAFElEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC";
|
|
|
|
|
|
/** Unpacks a stored-only ZIP far enough to list names and read text entries. */
|
|
function readZip(bytes) {
|
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
const dec = new TextDecoder();
|
|
const endAt = bytes.length - 22;
|
|
const count = view.getUint16(endAt + 8, true);
|
|
let p = view.getUint32(endAt + 16, true);
|
|
const out = {};
|
|
for (let i = 0; i < count; i++) {
|
|
const size = view.getUint32(p + 24, true);
|
|
const nameLen = view.getUint16(p + 28, true);
|
|
const localAt = view.getUint32(p + 42, true);
|
|
const name = dec.decode(bytes.slice(p + 46, p + 46 + nameLen));
|
|
const dataAt = localAt + 30 + view.getUint16(localAt + 26, true) + view.getUint16(localAt + 28, true);
|
|
out[name] = bytes.slice(dataAt, dataAt + size);
|
|
p += 46 + nameLen + view.getUint16(p + 30, true) + view.getUint16(p + 32, true);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const openExportDialog = async (page) => {
|
|
await page.getByTitle("Export").click();
|
|
await page.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
|
await page.waitForTimeout(800);
|
|
};
|
|
|
|
/** Draws a couple of strokes so the note has something to export as a picture. */
|
|
async function drawSomething(page) {
|
|
await page.locator("button").filter({ hasText: "Draw" }).first().click();
|
|
await page.waitForTimeout(1200);
|
|
const box = await page.locator("canvas").first().boundingBox();
|
|
for (const [x1, y1, x2, y2] of [
|
|
[0.2, 0.25, 0.7, 0.4],
|
|
[0.3, 0.6, 0.7, 0.55],
|
|
]) {
|
|
await page.mouse.move(box.x + box.width * x1, box.y + box.height * y1);
|
|
await page.mouse.down();
|
|
await page.mouse.move(box.x + box.width * x2, box.y + box.height * y2, { steps: 12 });
|
|
await page.mouse.up();
|
|
await page.waitForTimeout(250);
|
|
}
|
|
await page.waitForTimeout(2500);
|
|
await page.locator("button").filter({ hasText: "Write" }).first().click();
|
|
await page.waitForTimeout(800);
|
|
}
|
|
|
|
const browser = await launch();
|
|
let session;
|
|
try {
|
|
session = await withFreshUser(browser, "export");
|
|
const p = session.page;
|
|
await createNotebook(p, "Export Buch");
|
|
|
|
/* ── a note with everything that is hard to serialise ── */
|
|
await createNote(p);
|
|
await setTitle(p, "Alles drin");
|
|
await typeInEditor(p, "Einleitender Satz.");
|
|
await insertTable(p);
|
|
await p.locator(".ProseMirror table th").first().click();
|
|
await p.keyboard.type("Spalte");
|
|
await p.waitForTimeout(600);
|
|
await p.locator(".ProseMirror").click();
|
|
await p.keyboard.press("Control+End");
|
|
await p.keyboard.press("Enter");
|
|
await p.getByTitle("Task list").click();
|
|
await p.waitForTimeout(400);
|
|
await p.keyboard.type("Erledigte Sache");
|
|
await p.waitForTimeout(800);
|
|
await p.locator('.ProseMirror input[type="checkbox"]').first().click();
|
|
await p.waitForTimeout(1800);
|
|
|
|
await captureDownloads(p);
|
|
|
|
/* ── single note as Markdown ── */
|
|
await openExportDialog(p);
|
|
await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click();
|
|
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
|
await p.waitForTimeout(600);
|
|
|
|
let files = await downloads(p);
|
|
check(files.length === 1, "a single note produces one download", `${files.length}`);
|
|
check(/\.md$/.test(await lastDownloadName(p)), "it is named .md", await lastDownloadName(p));
|
|
|
|
const md = Buffer.from(files[0].base64, "base64").toString("utf8");
|
|
check(md.startsWith("# Alles drin"), "the title became a heading");
|
|
check(md.includes("Einleitender Satz."), "the body text is there");
|
|
check(/\|\s*Spalte\s*\|/.test(md), "the table came along", md.match(/\|.*\|/)?.[0] || "no table row");
|
|
check(md.includes("| --- |"), "the table has a separator row so it renders as a table");
|
|
check(md.includes("- [x] Erledigte Sache"), "the ticked task is a ticked checkbox");
|
|
console.log("--- Markdown ---\n" + md + "\n--- Ende ---");
|
|
|
|
// The dialog stays up after an export so the result can be read; close it or
|
|
// its backdrop swallows the next click.
|
|
await p.keyboard.press("Escape");
|
|
await p.waitForTimeout(600);
|
|
|
|
/* ── a second note and an image, then the whole notebook ── */
|
|
await createNote(p);
|
|
await setTitle(p, "Zweite Notiz");
|
|
await typeInEditor(p, "Kurzer Text mit Bild.");
|
|
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 dt = new DataTransfer();
|
|
dt.items.add(new File([bytes], "dot.png", { type: "image/png" }));
|
|
document
|
|
.querySelector(".ProseMirror")
|
|
.dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true }));
|
|
}, PNG);
|
|
await p.waitForTimeout(3500);
|
|
|
|
await clearDownloads(p);
|
|
await openExportDialog(p);
|
|
await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click();
|
|
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
|
await p.waitForTimeout(800);
|
|
files = await downloads(p);
|
|
check(files.length === 1 && /zip/.test(files[0].type), "a note with an image is packed into a ZIP", files[0]?.type);
|
|
|
|
const zip = readZip(new Uint8Array(Buffer.from(files[0].base64, "base64")));
|
|
const names = Object.keys(zip);
|
|
check(names.some((n) => n.endsWith(".md")), "the archive holds the Markdown file", names.join(", "));
|
|
check(names.some((n) => /images\/.*\.png$/.test(n)), "and the image beside it", names.join(", "));
|
|
|
|
await p.keyboard.press("Escape");
|
|
await p.waitForTimeout(600);
|
|
|
|
const imgBytes = zip[names.find((n) => n.endsWith(".png"))];
|
|
check(
|
|
imgBytes[0] === 0x89 && imgBytes[1] === 0x50 && imgBytes[2] === 0x4e && imgBytes[3] === 0x47,
|
|
"the extracted image really is a PNG",
|
|
Array.from(imgBytes.slice(0, 4)).join(",")
|
|
);
|
|
await p.screenshot({ path: `${OUT}/export-dialog.png` });
|
|
|
|
/* ── the wizard on a note that has a drawing ── */
|
|
await createNote(p);
|
|
await setTitle(p, "Mit Zeichnung");
|
|
await typeInEditor(p, "Text und Bild.");
|
|
await drawSomething(p);
|
|
|
|
await clearDownloads(p);
|
|
await openExportDialog(p);
|
|
check(
|
|
(await p.getByText("What should come along?").count()) > 0,
|
|
"a note with a drawing is asked what should come along"
|
|
);
|
|
check(
|
|
(await p.getByRole("button", { name: /Only the drawing/ }).count()) > 0,
|
|
"the drawing-only option is offered"
|
|
);
|
|
await p.screenshot({ path: `${OUT}/export-wizard.png` });
|
|
|
|
// drawing only → PNG
|
|
await p.getByRole("button", { name: /Only the drawing/ }).click();
|
|
await p.waitForTimeout(500);
|
|
check((await p.getByRole("button", { name: /PNG image/ }).count()) > 0, "PNG is offered for the drawing");
|
|
check(
|
|
(await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).count()) === 0,
|
|
"Markdown is not offered for a drawing, since it cannot hold one"
|
|
);
|
|
await p.getByRole("button", { name: /PNG image/ }).click();
|
|
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
|
await p.waitForTimeout(800);
|
|
|
|
files = await downloads(p);
|
|
check(files.length === 1 && files[0].type === "image/png", "the PNG was produced", files[0]?.type);
|
|
const png = Buffer.from(files[0].base64, "base64");
|
|
check(
|
|
png[0] === 0x89 && png[1] === 0x50 && png[2] === 0x4e && png[3] === 0x47,
|
|
"and it really is a PNG",
|
|
Array.from(png.slice(0, 4)).join(",")
|
|
);
|
|
check(/\.png$/.test(await lastDownloadName(p)), "named .png", await lastDownloadName(p));
|
|
check(png.length > 1000, "the image has actual content, not an empty canvas", `${png.length} bytes`);
|
|
|
|
// back, then text only → Markdown must carry no drawing
|
|
await p.getByRole("button", { name: /Back/ }).click();
|
|
await p.waitForTimeout(500);
|
|
await p.getByRole("button", { name: /Only the note text/ }).click();
|
|
await p.waitForTimeout(500);
|
|
await clearDownloads(p);
|
|
await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click();
|
|
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
|
await p.waitForTimeout(800);
|
|
files = await downloads(p);
|
|
const textMd = Buffer.from(files[0].base64, "base64").toString("utf8");
|
|
check(textMd.includes("Text und Bild."), "the text-only export has the text");
|
|
await p.keyboard.press("Escape");
|
|
await p.waitForTimeout(600);
|
|
|
|
/* ── whole notebook, via the sidebar's context menu ── */
|
|
await clearDownloads(p);
|
|
await p.locator("[data-note-row]").first().click({ button: "right" });
|
|
await p.waitForTimeout(600);
|
|
await p.getByText("Export...", { exact: true }).click();
|
|
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
|
await p.waitForTimeout(500);
|
|
check(true, "the context menu opens the export dialog");
|
|
await p.keyboard.press("Escape");
|
|
} catch (e) {
|
|
check(false, "suite ran to the end", e.message);
|
|
} finally {
|
|
await session?.cleanup();
|
|
const code = report();
|
|
await browser.close();
|
|
process.exit(code);
|
|
}
|