Files
InkNote/frontend/src/export/exportNotes.ts
T
marcandClaude Opus 5 52bd2b8f3d Put the interface into English, and give the notebook one way in
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>
2026-08-11 02:17:22 +02:00

336 lines
12 KiB
TypeScript

/**
* 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, docStrokes, paintStrokeDoc, sizeCanvas } from "../features/drawing/strokes.js";
import type { NoteContent, NoteMeta, Notebook } from "../types";
import { noteToMarkdown, safeFilename } from "./markdown";
import { asPdfStrokeDoc, hasOverlay } from "../features/pdf/pageStrokes";
import { stampStrokesIntoPdf } from "../features/pdf/stampPdf";
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(doc: unknown): Promise<Uint8Array | null> {
// `doc` is already a stroke document as stored — strokeDocOf() builds one from
// 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 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, "Note");
let name = base;
for (let i = 2; taken.has(name.toLowerCase()); i++) name = `${base} (${i})`;
taken.add(name.toLowerCase());
return name;
}
/* ── PDF notes ── */
/**
* Exports the document behind a PDF note.
*
* `withInk` decides whether the drawings come along. Without it the bytes are
* handed over exactly as imported — the original is never modified in storage,
* so a clean copy stays available however often it has been drawn on.
*/
export async function exportPdfNote(meta: NoteMeta, withInk: boolean): Promise<void> {
if (!meta.pdfAttachmentId) throw new Error("This note has no PDF attached.");
const bytes = await sync.loadPdfBytes(meta.pdfAttachmentId);
const name = safeFilename(meta.title, "Document");
if (!withInk) {
download(bytesToBlob(bytes, "application/pdf"), `${name}.pdf`);
return;
}
const { content } = await loadNote(meta);
const ink = asPdfStrokeDoc(content.drawingStrokes);
// Pinned notes count as much as strokes here: both are the reader's own work
// on the document, and an export that dropped either would lose it silently.
if (!hasOverlay(ink)) {
download(bytesToBlob(bytes, "application/pdf"), `${name}.pdf`);
return;
}
const stamped = await stampStrokesIntoPdf(bytes, ink);
download(bytesToBlob(stamped, "application/pdf"), `${name} (annotated).pdf`);
}
/** True when the PDF carries drawings or pinned notes, so the wizard can say so. */
export async function pdfNoteHasInk(meta: NoteMeta): Promise<boolean> {
try {
const { content } = await loadNote(meta);
return hasOverlay(asPdfStrokeDoc(content.drawingStrokes));
} catch {
return false;
}
}
/* ── 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("This note has no drawing.");
download(bytesToBlob(png, "image/png"), `${safeFilename(meta.title)}.png`);
}
export async function exportNoteAsMarkdown(meta: NoteMeta): Promise<void> {
const { content } = await loadNote(meta);
const doc = parseNoteContent(content.content, parsingExtensions);
const hasDrawing = docStrokes(content.drawingStrokes).length > 0;
const ids = attachmentIds(doc);
const md = noteToMarkdown(doc, {
title: meta.title,
updatedAt: meta.updatedAt,
hasDrawing,
attachmentPath: (id) => `${safeFilename(meta.title)}-images/${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}-images/${id}.png`, data: bytes });
}
const png = await drawingToPng(content.drawingStrokes);
if (png) entries.push({ name: `${folder}-drawing.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,
parts: ExportParts = "both"
): 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 = parts === "text" ? null : await drawingToPng(content.drawingStrokes);
const drawing = png ? await blobToDataUrl(bytesToBlob(png, "image/png")) : null;
printable.push({ meta, content, drawing, images, includeText: parts !== "drawing" });
} catch {
/* an unreadable note is left out rather than aborting the whole print */
}
}
onProgress?.({ done: total, total, label: "Opening the print view" });
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} notes, exported ${new Date().toLocaleString()}`,
"",
];
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 = docStrokes(content.drawingStrokes).length > 0;
entries.push({
name: `${name}.md`,
data: noteToMarkdown(doc, {
title: meta.title,
updatedAt: meta.updatedAt,
hasDrawing,
attachmentPath: (id) => `images/${id}.png`,
notePath,
}),
});
for (const id of attachmentIds(doc)) {
if (entries.some((e) => e.name === `images/${id}.png`)) continue;
const bytes = await fetchAttachment(id);
if (bytes) entries.push({ name: `images/${id}.png`, data: bytes });
}
const png = await drawingToPng(content.drawingStrokes);
if (png) entries.push({ name: `drawings/${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> This note could not be read when exporting.\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 || "No notebook"}`, "");
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`);
}