Export notes as Markdown or PDF
The reason to build this is not a missing feature — it is that trusting a program you maintain alone with years of notes should not mean the notes can only ever be read by that program. Reachable from a note's toolbar, a notebook's context menu, and Account → Alle Notizen exportieren. Markdown carries over everything with an equivalent: headings, lists, quotes, code, tables, and task state as `- [x]`. A link to another note in the same export becomes a relative link to its file; a link to a note outside it degrades to the plain title rather than leaving a dead reference. Notes with images or a drawing arrive as a ZIP so the files travel along, and a multi-note export gets a README index. PDF goes through the browser's print dialog rather than a bundled PDF library — the browser already has a good one, and it is the only format where drawings sit inside the document. Markdown gets them as a PNG beside the text, with a line saying so, because dropping them silently would be the wrong kind of quiet. Two decisions worth recording: - the ZIP writer is eighty lines here rather than a dependency. Note text is small and images are already compressed, so compression bought nothing, and stored entries are a handful of fixed-layout records. - Markdown escaping only covers what changes meaning mid-line, plus a leading list or heading marker. Escaping every character Markdown ever uses turns a sentence into `Hallo\. Alles gut\!`, which defeats the point of choosing a readable format. 42 unit tests on the serialiser and the archive format, plus a browser suite that unpacks a real download and reads the bytes back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
311e992496
commit
2611d87f8c
14 files changed
+1707
-2
No files matched your search
+184
@@ -0,0 +1,184 @@
|
||||
/* 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,
|
||||
check,
|
||||
createNote,
|
||||
createNotebook,
|
||||
insertTable,
|
||||
launch,
|
||||
report,
|
||||
setTitle,
|
||||
typeInEditor,
|
||||
withFreshUser,
|
||||
} from "./lib.mjs";
|
||||
|
||||
const PNG =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAYAAABWKLW/AAAAFElEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC";
|
||||
|
||||
/** Intercepts download() so the bytes stay in the page and can be inspected. */
|
||||
async function captureDownloads(page) {
|
||||
await page.evaluate(() => {
|
||||
window.__downloads = [];
|
||||
const realCreate = URL.createObjectURL.bind(URL);
|
||||
URL.createObjectURL = (blob) => {
|
||||
const url = realCreate(blob);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () =>
|
||||
window.__downloads.push({
|
||||
url,
|
||||
type: blob.type,
|
||||
size: blob.size,
|
||||
// base64 of the payload, so binary survives the trip out of the page
|
||||
base64: String(reader.result).split(",")[1] || "",
|
||||
});
|
||||
reader.readAsDataURL(blob);
|
||||
return url;
|
||||
};
|
||||
// A real navigation would abandon the test run.
|
||||
HTMLAnchorElement.prototype.click = function () {
|
||||
window.__lastDownloadName = this.download;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const downloads = (page) => page.evaluate(() => window.__downloads || []);
|
||||
const lastName = (page) => page.evaluate(() => window.__lastDownloadName || "");
|
||||
|
||||
/** 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(500);
|
||||
};
|
||||
|
||||
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.getByRole("button", { name: /Als 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 lastName(p)), "it is named .md", await lastName(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 p.evaluate(() => (window.__downloads = []));
|
||||
await openExportDialog(p);
|
||||
await p.getByRole("button", { name: /Als 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) => /bilder\/.*\.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` });
|
||||
|
||||
/* ── whole notebook, via the sidebar's context menu ── */
|
||||
await p.evaluate(() => (window.__downloads = []));
|
||||
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);
|
||||
}
|
||||
+1
-1
@@ -13,7 +13,7 @@ cd "$(dirname "$0")"
|
||||
IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble"
|
||||
URL="${INKNOTE_URL:-http://localhost:3000}"
|
||||
SUITES=("$@")
|
||||
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing)
|
||||
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export)
|
||||
|
||||
mkdir -p shots
|
||||
if [ ! -d node_modules ]; then
|
||||
|
||||
Reference in new issue
Block a user