Three things that all came from chapters being thought through too late. A note put into a chapter came back out of it a few seconds later. The server had it right the whole time; every sync rebuilds the local copy from the server's answer, and the function doing that never carried chapter_id — so each sync handed back a note belonging to no chapter and the notebook took it in again. The bin's query left the column out too, which would have unfiled anything restored from it. A separator drawn in a notebook vanished when a chapter was opened. "Which chapter is open" and "which list is on screen" were one value. Opened beside or below, the list still shows the notebook — but everything around it had switched to the chapter, so the notebook's own separators were filtered away and a drag among its rows was measured against the chapter's notes, found nothing, and did nothing. And where a new note lands is now visible and yours to choose: pressing in either place makes it the one being worked in, marked with a line along its top and the word "here", and the + button names it. Rearranging becomes a mode, switched on beside the sort menu — until then no row wears a grip and none can be dragged. Filing a note is back in the row's own menu, where starring and exporting already are; the button that had appeared in every row is gone with its dialog. The account panel closes with Escape. Thirteen suites green: 335 browser checks, 241 unit tests. 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", { exact: true }).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);
|
|
}
|