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
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Turning notes into files on disk.
|
||||
*
|
||||
* The point of this is that the notes are not hostage to this program. A single
|
||||
* note becomes one Markdown file; a notebook becomes a ZIP with the notes, their
|
||||
* images, and any drawings rendered as PNG — everything readable without InkNote
|
||||
* ever running again.
|
||||
*/
|
||||
|
||||
import * as sync from "../syncEngine";
|
||||
import { parseNoteContent } from "../editor/content";
|
||||
import { parsingExtensions } from "../editor/extensions.js";
|
||||
import { CANON_W, CANON_H, paintStrokeDoc, sizeCanvas, strokeDocOf } from "../features/drawing/strokes.js";
|
||||
import type { NoteContent, NoteMeta, Notebook } from "../types";
|
||||
import { noteToMarkdown, safeFilename } from "./markdown";
|
||||
import { printNotes, type PrintableNote } from "./print";
|
||||
import { bytesToBlob, createZip, download, type ZipEntry } from "./zip";
|
||||
|
||||
/** Width the exported drawing is rendered at; the page ratio is kept. */
|
||||
const DRAWING_EXPORT_W = 1200;
|
||||
|
||||
export interface ExportProgress {
|
||||
done: number;
|
||||
total: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
type Loaded = { meta: NoteMeta; content: NoteContent };
|
||||
|
||||
/* ── pieces ── */
|
||||
|
||||
/** Renders a stroke document to PNG bytes, so a drawing survives as a picture. */
|
||||
async function drawingToPng(strokes: unknown): Promise<Uint8Array | null> {
|
||||
const doc = strokeDocOf(strokes);
|
||||
if (!doc?.strokes?.length) return null;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const height = Math.round((DRAWING_EXPORT_W * CANON_H) / CANON_W);
|
||||
const { bw, bh } = sizeCanvas(canvas, DRAWING_EXPORT_W, height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
paintStrokeDoc(ctx, doc, bw, bh);
|
||||
// Strokes are drawn on nothing, which would export as a transparent PNG and
|
||||
// leave black ink invisible in a dark image viewer. The paper goes in behind
|
||||
// afterwards, because paintStrokeDoc begins by clearing the canvas.
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.globalCompositeOperation = "destination-over";
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, bw, bh);
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
|
||||
const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, "image/png"));
|
||||
if (!blob) return null;
|
||||
return new Uint8Array(await blob.arrayBuffer());
|
||||
}
|
||||
|
||||
/** Every attachment id the document refers to. */
|
||||
function attachmentIds(doc: unknown): string[] {
|
||||
const ids = new Set<string>();
|
||||
const walk = (n: any) => {
|
||||
if (!n) return;
|
||||
if (n.type === "image" && n.attrs?.attachmentId) ids.add(n.attrs.attachmentId);
|
||||
if (Array.isArray(n.content)) n.content.forEach(walk);
|
||||
};
|
||||
walk(doc);
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
async function loadNote(meta: NoteMeta): Promise<Loaded> {
|
||||
return { meta, content: await sync.loadNoteContent(meta.id) };
|
||||
}
|
||||
|
||||
/** Unique filename per note — two notes may well share a title. */
|
||||
function nameFor(meta: NoteMeta, taken: Set<string>): string {
|
||||
const base = safeFilename(meta.title, "Notiz");
|
||||
let name = base;
|
||||
for (let i = 2; taken.has(name.toLowerCase()); i++) name = `${base} (${i})`;
|
||||
taken.add(name.toLowerCase());
|
||||
return name;
|
||||
}
|
||||
|
||||
/* ── single note ── */
|
||||
|
||||
export async function exportNoteAsMarkdown(meta: NoteMeta): Promise<void> {
|
||||
const { content } = await loadNote(meta);
|
||||
const doc = parseNoteContent(content.content, parsingExtensions);
|
||||
const hasDrawing = !!strokeDocOf(content.drawingStrokes)?.strokes?.length;
|
||||
const ids = attachmentIds(doc);
|
||||
|
||||
const md = noteToMarkdown(doc, {
|
||||
title: meta.title,
|
||||
updatedAt: meta.updatedAt,
|
||||
hasDrawing,
|
||||
attachmentPath: (id) => `${safeFilename(meta.title)}-bilder/${id}.png`,
|
||||
notePath: () => null, // a lone note has nothing to link to
|
||||
});
|
||||
|
||||
// Only worth an archive if there is something to put beside the text.
|
||||
if (ids.length === 0 && !hasDrawing) {
|
||||
download(new Blob([md], { type: "text/markdown;charset=utf-8" }), `${safeFilename(meta.title)}.md`);
|
||||
return;
|
||||
}
|
||||
|
||||
const folder = safeFilename(meta.title);
|
||||
const entries: ZipEntry[] = [{ name: `${folder}.md`, data: md }];
|
||||
for (const id of ids) {
|
||||
const bytes = await fetchAttachment(id);
|
||||
if (bytes) entries.push({ name: `${folder}-bilder/${id}.png`, data: bytes });
|
||||
}
|
||||
const png = await drawingToPng(content.drawingStrokes);
|
||||
if (png) entries.push({ name: `${folder}-zeichnung.png`, data: png });
|
||||
|
||||
download(createZip(entries), `${folder}.zip`);
|
||||
}
|
||||
|
||||
/* ── PDF ── */
|
||||
|
||||
const blobToDataUrl = (blob: Blob): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => resolve(String(r.result));
|
||||
r.onerror = () => reject(r.error);
|
||||
r.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
/**
|
||||
* Prepares notes for the print dialog.
|
||||
*
|
||||
* Images have to travel as data URLs: the print document lives in its own
|
||||
* iframe, where a blob: URL minted by the app is not resolvable, and an
|
||||
* authenticated request is not something a printed page can make.
|
||||
*/
|
||||
export async function exportNotesAsPdf(
|
||||
metas: NoteMeta[],
|
||||
title: string,
|
||||
onProgress?: (p: ExportProgress) => void
|
||||
): Promise<void> {
|
||||
const printable: PrintableNote[] = [];
|
||||
const total = metas.length;
|
||||
|
||||
for (const [i, meta] of metas.entries()) {
|
||||
onProgress?.({ done: i, total, label: meta.title });
|
||||
try {
|
||||
const content = await sync.loadNoteContent(meta.id);
|
||||
const doc = parseNoteContent(content.content, parsingExtensions);
|
||||
|
||||
const images = new Map<string, string>();
|
||||
for (const id of attachmentIds(doc)) {
|
||||
try {
|
||||
images.set(id, await blobToDataUrl(await sync.fetchAttachmentBlob(id)));
|
||||
} catch {
|
||||
/* a missing image should not stop the rest of the page */
|
||||
}
|
||||
}
|
||||
|
||||
const png = await drawingToPng(content.drawingStrokes);
|
||||
const drawing = png ? await blobToDataUrl(bytesToBlob(png, "image/png")) : null;
|
||||
|
||||
printable.push({ meta, content, drawing, images });
|
||||
} catch {
|
||||
/* an unreadable note is left out rather than aborting the whole print */
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.({ done: total, total, label: "Druckansicht wird geöffnet" });
|
||||
await printNotes(printable, title);
|
||||
}
|
||||
|
||||
async function fetchAttachment(id: string): Promise<Uint8Array | null> {
|
||||
try {
|
||||
const blob = await sync.fetchAttachmentBlob(id);
|
||||
return new Uint8Array(await blob.arrayBuffer());
|
||||
} catch {
|
||||
// One unreachable image must not cost the whole export.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── many notes ── */
|
||||
|
||||
export interface ExportSetOptions {
|
||||
/** Shown in the archive name and its index file. */
|
||||
label: string;
|
||||
notes: NoteMeta[];
|
||||
notebooks: Notebook[];
|
||||
onProgress?: (p: ExportProgress) => void;
|
||||
}
|
||||
|
||||
export async function exportNotesAsZip({
|
||||
label,
|
||||
notes,
|
||||
notebooks,
|
||||
onProgress,
|
||||
}: ExportSetOptions): Promise<void> {
|
||||
const total = notes.length + 1;
|
||||
const entries: ZipEntry[] = [];
|
||||
const taken = new Set<string>();
|
||||
const filenames = new Map<string, string>();
|
||||
|
||||
// Names are settled first, so a link from the first note to the last one can
|
||||
// already point at the right file.
|
||||
for (const meta of notes) filenames.set(meta.id, nameFor(meta, taken));
|
||||
const notePath = (id: string) => {
|
||||
const name = filenames.get(id);
|
||||
return name ? `${encodeURIComponent(name)}.md` : null;
|
||||
};
|
||||
|
||||
const index: string[] = [
|
||||
`# ${label}`,
|
||||
"",
|
||||
`${notes.length} Notizen, exportiert am ${new Date().toLocaleString("de-DE")}`,
|
||||
"",
|
||||
];
|
||||
const byNotebook = new Map<string | null, NoteMeta[]>();
|
||||
for (const n of notes) {
|
||||
const key = n.folderId ?? null;
|
||||
if (!byNotebook.has(key)) byNotebook.set(key, []);
|
||||
byNotebook.get(key)!.push(n);
|
||||
}
|
||||
|
||||
let done = 0;
|
||||
for (const meta of notes) {
|
||||
onProgress?.({ done, total, label: meta.title });
|
||||
const name = filenames.get(meta.id)!;
|
||||
try {
|
||||
const { content } = await loadNote(meta);
|
||||
const doc = parseNoteContent(content.content, parsingExtensions);
|
||||
const hasDrawing = !!strokeDocOf(content.drawingStrokes)?.strokes?.length;
|
||||
|
||||
entries.push({
|
||||
name: `${name}.md`,
|
||||
data: noteToMarkdown(doc, {
|
||||
title: meta.title,
|
||||
updatedAt: meta.updatedAt,
|
||||
hasDrawing,
|
||||
attachmentPath: (id) => `bilder/${id}.png`,
|
||||
notePath,
|
||||
}),
|
||||
});
|
||||
|
||||
for (const id of attachmentIds(doc)) {
|
||||
if (entries.some((e) => e.name === `bilder/${id}.png`)) continue;
|
||||
const bytes = await fetchAttachment(id);
|
||||
if (bytes) entries.push({ name: `bilder/${id}.png`, data: bytes });
|
||||
}
|
||||
const png = await drawingToPng(content.drawingStrokes);
|
||||
if (png) entries.push({ name: `zeichnungen/${name}.png`, data: png });
|
||||
} catch (err) {
|
||||
// A note that cannot be read is named in the index rather than skipped in
|
||||
// silence — an export that quietly drops things is worse than useless.
|
||||
entries.push({
|
||||
name: `${name}.md`,
|
||||
data: `# ${meta.title}\n\n> Diese Notiz konnte beim Export nicht gelesen werden.\n> (${(err as Error).message})\n`,
|
||||
});
|
||||
}
|
||||
done++;
|
||||
}
|
||||
|
||||
for (const [notebookId, group] of byNotebook) {
|
||||
const nb = notebooks.find((b) => b.id === notebookId);
|
||||
index.push("", `## ${nb?.name || "Ohne Notizbuch"}`, "");
|
||||
for (const n of group) index.push(`- [${n.title || "Untitled"}](${notePath(n.id)})`);
|
||||
}
|
||||
entries.unshift({ name: "README.md", data: index.join("\n") + "\n" });
|
||||
|
||||
onProgress?.({ done: total, total, label: "Archiv wird gepackt" });
|
||||
download(createZip(entries), `${safeFilename(label, "InkNote")}.zip`);
|
||||
}
|
||||
Reference in new issue
Block a user