Ask what a single note's export should contain, and fix drawings never being in one

Two questions instead of one, because the answers depend on each other:
Markdown holds text only, a PNG holds the drawing only, PDF holds both.
Listing every format flat would have offered combinations that cannot
exist. A note without a drawing skips the first step rather than showing
it mostly greyed out.

While testing that, the drawings turned out never to have made it into
any export at all — not the ZIP, not the PDF. strokeDocOf() *builds* a
stroke document from a bare array, and it was being handed one that was
already a document, so the strokes ended up nested one level too deep and
every length check read undefined. Nothing complained; the export simply
came out without the drawing. It went unnoticed because the notes the
earlier suite exported had none.

Now uses docStrokes(), and the suite draws on a note before exporting it,
checks the PNG's magic bytes, and checks it is 159 KB rather than an empty
white page — a check on the file merely existing would have passed against
the broken version too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-08 17:37:22 +02:00
1 parent 2611d87f8c
commit 8c36c6121b
5 files changed
+284 -57

No files matched your search

+79 -3
View File
@@ -72,9 +72,29 @@ function readZip(bytes) {
const openExportDialog = async (page) => { const openExportDialog = async (page) => {
await page.getByTitle("Export").click(); await page.getByTitle("Export").click();
await page.waitForSelector("[data-export-dialog]", { timeout: 10000 }); await page.waitForSelector("[data-export-dialog]", { timeout: 10000 });
await page.waitForTimeout(500); 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(); const browser = await launch();
let session; let session;
try { try {
@@ -104,7 +124,7 @@ try {
/* ── single note as Markdown ── */ /* ── single note as Markdown ── */
await openExportDialog(p); await openExportDialog(p);
await p.getByRole("button", { name: /Als Markdown/ }).click(); await p.getByRole("button", { name: /Markdown/ }).click();
await p.waitForSelector("[data-export-done]", { timeout: 30000 }); await p.waitForSelector("[data-export-done]", { timeout: 30000 });
await p.waitForTimeout(600); await p.waitForTimeout(600);
@@ -143,7 +163,7 @@ try {
await p.evaluate(() => (window.__downloads = [])); await p.evaluate(() => (window.__downloads = []));
await openExportDialog(p); await openExportDialog(p);
await p.getByRole("button", { name: /Als Markdown/ }).click(); await p.getByRole("button", { name: /Markdown/ }).click();
await p.waitForSelector("[data-export-done]", { timeout: 30000 }); await p.waitForSelector("[data-export-done]", { timeout: 30000 });
await p.waitForTimeout(800); await p.waitForTimeout(800);
files = await downloads(p); files = await downloads(p);
@@ -165,6 +185,62 @@ try {
); );
await p.screenshot({ path: `${OUT}/export-dialog.png` }); 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 p.evaluate(() => (window.__downloads = []));
await openExportDialog(p);
check(
(await p.getByText("Was soll mit?").count()) > 0,
"a note with a drawing is asked what should come along"
);
check(
(await p.getByRole("button", { name: /Nur die Zeichnung/ }).count()) > 0,
"the drawing-only option is offered"
);
await p.screenshot({ path: `${OUT}/export-wizard.png` });
// drawing only → PNG
await p.getByRole("button", { name: /Nur die Zeichnung/ }).click();
await p.waitForTimeout(500);
check((await p.getByRole("button", { name: /PNG-Bild/ }).count()) > 0, "PNG is offered for the drawing");
check(
(await p.getByRole("button", { name: /Markdown/ }).count()) === 0,
"Markdown is not offered for a drawing, since it cannot hold one"
);
await p.getByRole("button", { name: /PNG-Bild/ }).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 lastName(p)), "named .png", await lastName(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: /Zurück/ }).click();
await p.waitForTimeout(500);
await p.getByRole("button", { name: /Nur den Notiztext/ }).click();
await p.waitForTimeout(500);
await p.evaluate(() => (window.__downloads = []));
await p.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 ── */ /* ── whole notebook, via the sidebar's context menu ── */
await p.evaluate(() => (window.__downloads = [])); await p.evaluate(() => (window.__downloads = []));
await p.locator("[data-note-row]").first().click({ button: "right" }); await p.locator("[data-note-row]").first().click({ button: "right" });
+32 -9
View File
@@ -10,7 +10,7 @@
import * as sync from "../syncEngine"; import * as sync from "../syncEngine";
import { parseNoteContent } from "../editor/content"; import { parseNoteContent } from "../editor/content";
import { parsingExtensions } from "../editor/extensions.js"; import { parsingExtensions } from "../editor/extensions.js";
import { CANON_W, CANON_H, paintStrokeDoc, sizeCanvas, strokeDocOf } from "../features/drawing/strokes.js"; import { CANON_W, CANON_H, docStrokes, paintStrokeDoc, sizeCanvas } from "../features/drawing/strokes.js";
import type { NoteContent, NoteMeta, Notebook } from "../types"; import type { NoteContent, NoteMeta, Notebook } from "../types";
import { noteToMarkdown, safeFilename } from "./markdown"; import { noteToMarkdown, safeFilename } from "./markdown";
import { printNotes, type PrintableNote } from "./print"; import { printNotes, type PrintableNote } from "./print";
@@ -30,9 +30,10 @@ type Loaded = { meta: NoteMeta; content: NoteContent };
/* ── pieces ── */ /* ── pieces ── */
/** Renders a stroke document to PNG bytes, so a drawing survives as a picture. */ /** Renders a stroke document to PNG bytes, so a drawing survives as a picture. */
async function drawingToPng(strokes: unknown): Promise<Uint8Array | null> { async function drawingToPng(doc: unknown): Promise<Uint8Array | null> {
const doc = strokeDocOf(strokes); // `doc` is already a stroke document as stored — strokeDocOf() builds one from
if (!doc?.strokes?.length) return null; // a bare array and would wrap this one a second time, hiding the strokes.
if (docStrokes(doc).length === 0) return null;
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const height = Math.round((DRAWING_EXPORT_W * CANON_H) / CANON_W); const height = Math.round((DRAWING_EXPORT_W * CANON_H) / CANON_W);
@@ -81,10 +82,31 @@ function nameFor(meta: NoteMeta, taken: Set<string>): string {
/* ── single note ── */ /* ── single note ── */
/** What a single-note export should contain. */
export type ExportParts = "text" | "drawing" | "both";
/** Whether this note has a drawing — decides which options the wizard offers. */
export async function noteHasDrawing(meta: NoteMeta): Promise<boolean> {
try {
const { content } = await loadNote(meta);
return docStrokes(content.drawingStrokes).length > 0;
} catch {
return false;
}
}
/** The drawing on its own, as a PNG on white paper. */
export async function exportDrawingAsPng(meta: NoteMeta): Promise<void> {
const { content } = await loadNote(meta);
const png = await drawingToPng(content.drawingStrokes);
if (!png) throw new Error("Diese Notiz enthält keine Zeichnung.");
download(bytesToBlob(png, "image/png"), `${safeFilename(meta.title)}.png`);
}
export async function exportNoteAsMarkdown(meta: NoteMeta): Promise<void> { export async function exportNoteAsMarkdown(meta: NoteMeta): Promise<void> {
const { content } = await loadNote(meta); const { content } = await loadNote(meta);
const doc = parseNoteContent(content.content, parsingExtensions); const doc = parseNoteContent(content.content, parsingExtensions);
const hasDrawing = !!strokeDocOf(content.drawingStrokes)?.strokes?.length; const hasDrawing = docStrokes(content.drawingStrokes).length > 0;
const ids = attachmentIds(doc); const ids = attachmentIds(doc);
const md = noteToMarkdown(doc, { const md = noteToMarkdown(doc, {
@@ -133,7 +155,8 @@ const blobToDataUrl = (blob: Blob): Promise<string> =>
export async function exportNotesAsPdf( export async function exportNotesAsPdf(
metas: NoteMeta[], metas: NoteMeta[],
title: string, title: string,
onProgress?: (p: ExportProgress) => void onProgress?: (p: ExportProgress) => void,
parts: ExportParts = "both"
): Promise<void> { ): Promise<void> {
const printable: PrintableNote[] = []; const printable: PrintableNote[] = [];
const total = metas.length; const total = metas.length;
@@ -153,10 +176,10 @@ export async function exportNotesAsPdf(
} }
} }
const png = await drawingToPng(content.drawingStrokes); const png = parts === "text" ? null : await drawingToPng(content.drawingStrokes);
const drawing = png ? await blobToDataUrl(bytesToBlob(png, "image/png")) : null; const drawing = png ? await blobToDataUrl(bytesToBlob(png, "image/png")) : null;
printable.push({ meta, content, drawing, images }); printable.push({ meta, content, drawing, images, includeText: parts !== "drawing" });
} catch { } catch {
/* an unreadable note is left out rather than aborting the whole print */ /* an unreadable note is left out rather than aborting the whole print */
} }
@@ -225,7 +248,7 @@ export async function exportNotesAsZip({
try { try {
const { content } = await loadNote(meta); const { content } = await loadNote(meta);
const doc = parseNoteContent(content.content, parsingExtensions); const doc = parseNoteContent(content.content, parsingExtensions);
const hasDrawing = !!strokeDocOf(content.drawingStrokes)?.strokes?.length; const hasDrawing = docStrokes(content.drawingStrokes).length > 0;
entries.push({ entries.push({
name: `${name}.md`, name: `${name}.md`,
+8 -3
View File
@@ -138,21 +138,26 @@ const STYLES = `
export interface PrintableNote { export interface PrintableNote {
meta: NoteMeta; meta: NoteMeta;
content: NoteContent; content: NoteContent;
/** Drawing rendered as a data URL, if the note has one. */ /** Drawing rendered as a data URL, if the note has one and it was asked for. */
drawing?: string | null; drawing?: string | null;
/** Attachment id → data URL, for images to survive into the print document. */ /** Attachment id → data URL, for images to survive into the print document. */
images?: Map<string, string>; images?: Map<string, string>;
/** Omitted for a drawing-only export, where the text would be in the way. */
includeText?: boolean;
} }
function articleFor(note: PrintableNote): string { function articleFor(note: PrintableNote): string {
const doc = parseNoteContent(note.content.content, parsingExtensions);
const resolve = (id: string) => note.images?.get(id) || null; const resolve = (id: string) => note.images?.get(id) || null;
const when = note.meta.updatedAt ? new Date(note.meta.updatedAt).toLocaleString("de-DE") : ""; const when = note.meta.updatedAt ? new Date(note.meta.updatedAt).toLocaleString("de-DE") : "";
const body =
note.includeText === false
? ""
: blocks(parseNoteContent(note.content.content, parsingExtensions).content, resolve);
return `<article> return `<article>
<h1>${esc(note.meta.title || "Untitled")}</h1> <h1>${esc(note.meta.title || "Untitled")}</h1>
${when ? `<div class="meta">${esc(when)}</div>` : ""} ${when ? `<div class="meta">${esc(when)}</div>` : ""}
${blocks(doc.content, resolve)} ${body}
${note.drawing ? `<div class="drawing"><img src="${note.drawing}" alt="Zeichnung"></div>` : ""} ${note.drawing ? `<div class="drawing"><img src="${note.drawing}" alt="Zeichnung"></div>` : ""}
</article>`; </article>`;
} }
+151 -41
View File
@@ -1,18 +1,31 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { exportNoteAsMarkdown, exportNotesAsPdf, exportNotesAsZip } from "../export/exportNotes"; import {
exportDrawingAsPng,
exportNoteAsMarkdown,
exportNotesAsPdf,
exportNotesAsZip,
noteHasDrawing,
} from "../export/exportNotes";
import { I } from "./icons.jsx"; import { I } from "./icons.jsx";
import { FONT_SERIF, FONT_UI } from "./theme"; import { FONT_SERIF, FONT_UI } from "./theme";
/** /**
* Export as Markdown or PDF — used for a single note, a notebook, or everything. * Export as Markdown, PDF or PNG.
* *
* The formats are deliberately both offered rather than one being chosen for * A single note is asked two questions — what should come along, then in what
* you: Markdown keeps the notes editable somewhere else, PDF keeps them looking * form — because the two are not independent: Markdown is text only, a PNG is
* the way they look here. Drawings only survive in PDF and as a PNG beside the * the drawing only, and PDF is the one format that holds both. Offering all
* Markdown, which the dialog says out loud rather than leaving to be discovered. * combinations flat would list options that cannot exist.
*
* A selection of several notes skips the first question; drawings there always
* follow the format's own rule.
*/ */
export function ExportDialog({ notes, notebooks, label, dark, onClose }) { export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
const single = notes.length === 1;
const [parts, setParts] = useState(single ? null : "both");
const [hasDrawing, setHasDrawing] = useState(null); // null = still looking
const [busy, setBusy] = useState(null); const [busy, setBusy] = useState(null);
const [progress, setProgress] = useState(null); const [progress, setProgress] = useState(null);
const [err, setErr] = useState(""); const [err, setErr] = useState("");
@@ -33,33 +46,44 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
[dark] [dark]
); );
// A note without a drawing has nothing to choose, so the first step is skipped
// rather than shown with two thirds of it disabled.
useEffect(() => {
if (!single) return;
let cancelled = false;
noteHasDrawing(notes[0]).then((yes) => {
if (cancelled) return;
setHasDrawing(yes);
if (!yes) setParts("text");
});
return () => {
cancelled = true;
};
}, [single, notes]);
useEffect(() => { useEffect(() => {
const onKey = (e) => e.key === "Escape" && !busy && onClose(); const onKey = (e) => e.key === "Escape" && !busy && onClose();
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [busy, onClose]); }, [busy, onClose]);
const single = notes.length === 1;
const run = async (kind) => { const run = async (kind) => {
setErr(""); setErr("");
setDone(""); setDone("");
setBusy(kind); setBusy(kind);
setProgress(null); setProgress(null);
try { try {
if (kind === "markdown") { if (kind === "png") {
await exportDrawingAsPng(notes[0]);
setDone("Zeichnung wurde als PNG heruntergeladen.");
} else if (kind === "markdown") {
if (single) await exportNoteAsMarkdown(notes[0]); if (single) await exportNoteAsMarkdown(notes[0]);
else await exportNotesAsZip({ label, notes, notebooks, onProgress: setProgress }); else await exportNotesAsZip({ label, notes, notebooks, onProgress: setProgress });
setDone(single ? "Datei wurde heruntergeladen." : "Archiv wurde heruntergeladen.");
} else { } else {
await exportNotesAsPdf(notes, label, setProgress); await exportNotesAsPdf(notes, label, setProgress, parts || "both");
setDone("Druckansicht war geöffnet — wähle dort „Als PDF sichern“.");
} }
setDone(
kind === "markdown"
? single
? "Datei wurde heruntergeladen."
: "Archiv wurde heruntergeladen."
: "Druckansicht war geöffnet — wähle dort „Als PDF sichern“."
);
} catch (e) { } catch (e) {
setErr(e?.message || "Export fehlgeschlagen"); setErr(e?.message || "Export fehlgeschlagen");
} }
@@ -67,29 +91,30 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
setProgress(null); setProgress(null);
}; };
const option = (kind, title, lines) => ( const card = (onClick, active, title, lines, disabled = false) => (
<button <button
onClick={() => run(kind)} key={title}
disabled={!!busy} onClick={onClick}
disabled={disabled || !!busy}
style={{ style={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "flex-start", alignItems: "flex-start",
gap: 4, gap: 4,
width: "100%", width: "100%",
padding: "14px 16px", padding: "13px 16px",
borderRadius: 12, borderRadius: 12,
border: `1px solid ${T.border}`, border: `1px solid ${active ? T.accent : T.border}`,
background: T.surface, background: active ? T.accent + "14" : T.surface,
cursor: busy ? "default" : "pointer", cursor: disabled || busy ? "default" : "pointer",
textAlign: "left", textAlign: "left",
opacity: busy && busy !== kind ? 0.45 : 1, opacity: disabled ? 0.4 : busy && busy !== active ? 0.5 : 1,
marginBottom: 10, marginBottom: 10,
fontFamily: FONT_UI, fontFamily: FONT_UI,
}} }}
> >
<span style={{ fontSize: 14, fontWeight: 600, color: T.text }}>{busy === kind ? "Läuft…" : title}</span> <span style={{ fontSize: 14, fontWeight: 600, color: T.text }}>{title}</span>
{lines.map((l, i) => ( {lines.filter(Boolean).map((l, i) => (
<span key={i} style={{ fontSize: 12, color: T.muted, lineHeight: 1.5 }}> <span key={i} style={{ fontSize: 12, color: T.muted, lineHeight: 1.5 }}>
{l} {l}
</span> </span>
@@ -97,6 +122,84 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
</button> </button>
); );
/* ── step one: what should come along ── */
const stepParts = () => (
<>
<div style={{ fontSize: 12, color: T.muted, marginBottom: 10 }}>Was soll mit?</div>
{card(() => setParts("both"), parts === "both", "Notiz mit Zeichnung", [
"Text und Zeichnung in einem Dokument — nur als PDF möglich.",
])}
{card(() => setParts("text"), parts === "text", "Nur den Notiztext", [
"Ohne die Zeichnung. Als PDF oder als Markdown.",
])}
{card(() => setParts("drawing"), parts === "drawing", "Nur die Zeichnung", [
"Als PNG-Bild oder als PDF-Seite.",
])}
</>
);
/* ── step two: in what form ── */
const stepFormat = () => {
const options = [];
if (parts !== "drawing") {
options.push(
card(
() => run("markdown"),
busy === "markdown",
single ? "Markdown (.md)" : "Markdown-Archiv (ZIP)",
[
single
? "Bleibt anderswo bearbeitbar. Tabellen und Aufgabenhaken kommen mit."
: "Je Notiz eine .md-Datei, dazu Bilder und ein Inhaltsverzeichnis.",
parts === "both" ? "Die Zeichnung liegt als PNG daneben — Markdown kann sie nicht halten." : null,
]
)
);
}
if (parts === "drawing") {
options.push(
card(() => run("png"), busy === "png", "PNG-Bild (.png)", [
"Die Zeichnung in voller Auflösung, auf weißem Grund.",
])
);
}
options.push(
card(() => run("pdf"), busy === "pdf", "PDF", [
"Öffnet den Druckdialog — dort „Als PDF sichern“ wählen.",
parts === "both" ? "Die Zeichnung steht mit im Dokument." : null,
])
);
return (
<>
{single && hasDrawing && (
<button
onClick={() => setParts(null)}
disabled={!!busy}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: 0,
marginBottom: 12,
border: "none",
background: "transparent",
cursor: busy ? "default" : "pointer",
color: T.accent,
fontSize: 12,
fontWeight: 600,
}}
>
{I.back} Zurück
</button>
)}
<div style={{ fontSize: 12, color: T.muted, marginBottom: 10 }}>In welcher Form?</div>
{options}
</>
);
};
const subtitle = single ? label : `${label} · ${notes.length} Notiz${notes.length === 1 ? "" : "en"}`;
return ( return (
<div <div
style={{ style={{
@@ -134,12 +237,21 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
borderBottom: `1px solid ${T.border}`, borderBottom: `1px solid ${T.border}`,
}} }}
> >
<div> <div style={{ minWidth: 0 }}>
<h3 style={{ margin: 0, fontSize: 17, fontWeight: 600, color: T.text, fontFamily: FONT_SERIF }}> <h3 style={{ margin: 0, fontSize: 17, fontWeight: 600, color: T.text, fontFamily: FONT_SERIF }}>
Exportieren Exportieren
</h3> </h3>
<div style={{ fontSize: 12, color: T.muted, marginTop: 2 }}> <div
{single ? label : `${label} · ${notes.length} Notizen`} style={{
fontSize: 12,
color: T.muted,
marginTop: 2,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{subtitle}
</div> </div>
</div> </div>
<button <button
@@ -155,6 +267,7 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
background: "transparent", background: "transparent",
cursor: busy ? "default" : "pointer", cursor: busy ? "default" : "pointer",
color: T.muted, color: T.muted,
flexShrink: 0,
}} }}
> >
{I.x} {I.x}
@@ -162,19 +275,16 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
</div> </div>
<div style={{ padding: "16px 22px 8px" }}> <div style={{ padding: "16px 22px 8px" }}>
{option("markdown", single ? "Als Markdown" : "Als Markdown-Archiv (ZIP)", [ {single && hasDrawing === null ? (
single <div style={{ fontSize: 13, color: T.muted, padding: "12px 0" }}>Notiz wird gelesen…</div>
? "Eine .md-Datei — Bilder und Zeichnung kommen als ZIP mit, falls vorhanden." ) : parts === null ? (
: "Je Notiz eine .md-Datei, dazu Bilder, Zeichnungen und ein Inhaltsverzeichnis.", stepParts()
"Bleibt anderswo bearbeitbar. Tabellen und Aufgabenhaken bleiben erhalten.", ) : (
])} stepFormat()
{option("pdf", "Als PDF", [ )}
"Öffnet den Druckdialog — dort „Als PDF sichern“ wählen.",
"Zeichnungen sind hier mit im Dokument.",
])}
</div> </div>
{(progress || err || done) && ( {parts !== null && (progress || err || done) && (
<div style={{ padding: "0 22px 8px" }}> <div style={{ padding: "0 22px 8px" }}>
{progress && ( {progress && (
<div style={{ fontSize: 12, color: T.muted }}> <div style={{ fontSize: 12, color: T.muted }}>
+14 -1
View File
@@ -332,7 +332,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes | | `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable | | `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
| `drawing` | 7 | strokes survive the poller, and match across four viewport widths | | `drawing` | 7 | strokes survive the poller, and match across four viewport widths |
| `export` | 12 | the downloaded bytes are real Markdown and a real ZIP, unpacked and read back | | `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
Each suite creates a throwaway account through the admin API and deletes it Each suite creates a throwaway account through the admin API and deletes it
afterwards, so runs leave the database as they found it. They need an admin to afterwards, so runs leave the database as they found it. They need an admin to
@@ -485,6 +485,19 @@ Notes should not be hostage to this program, so everything can leave it — from
single note up to every note in the account, reachable from the note's toolbar, single note up to every note in the account, reachable from the note's toolbar,
a notebook's context menu, and Account → *Alle Notizen exportieren*. a notebook's context menu, and Account → *Alle Notizen exportieren*.
A single note is asked two questions rather than one, because the answers are
not independent — Markdown holds text only, a PNG holds the drawing only, and
PDF is the one format that holds both:
| Was soll mit? | In welcher Form? |
| --- | --- |
| Notiz mit Zeichnung | PDF *(Markdown puts the drawing in a PNG beside it)* |
| Nur den Notiztext | PDF · Markdown |
| Nur die Zeichnung | PNG · PDF |
A note without a drawing skips the first question instead of showing it with two
thirds greyed out.
**Markdown** keeps the notes editable elsewhere. Headings, lists, quotes, code, **Markdown** keeps the notes editable elsewhere. Headings, lists, quotes, code,
tables and task state all have equivalents and are carried over; a link to tables and task state all have equivalents and are carried over; a link to
another exported note becomes a relative link to its file, and a link to a note another exported note becomes a relative link to its file, and a link to a note