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,237 @@
|
||||
/**
|
||||
* A note as Markdown.
|
||||
*
|
||||
* This exists so the notes are not trapped in one program. It is a plain
|
||||
* function over the stored document — no editor, no DOM — which is also why it
|
||||
* can be unit tested and reused by the PDF path.
|
||||
*
|
||||
* Where Markdown has no equivalent the export says so rather than dropping
|
||||
* something silently: a drawing becomes a marker line, an attachment becomes a
|
||||
* link to a file that sits beside the note in the archive.
|
||||
*/
|
||||
|
||||
import type { DocNode } from "../editor/content";
|
||||
|
||||
export interface MarkdownOptions {
|
||||
/** Heading line for the note, usually its title. Omit to export the body only. */
|
||||
title?: string;
|
||||
/** ISO timestamp rendered under the title. */
|
||||
updatedAt?: string;
|
||||
/** Note has a drawing that Markdown cannot hold. */
|
||||
hasDrawing?: boolean;
|
||||
/** Maps an attachment id to a path in the archive, e.g. `images/ab12.png`. */
|
||||
attachmentPath?: (id: string) => string;
|
||||
/** Maps a linked note id to a path, so links between exported notes still work. */
|
||||
notePath?: (id: string) => string | null;
|
||||
}
|
||||
|
||||
/* ── inline ── */
|
||||
|
||||
/**
|
||||
* Escapes only what actually changes meaning mid-line.
|
||||
*
|
||||
* Escaping every punctuation mark that Markdown ever uses turns an ordinary
|
||||
* sentence into `Hallo\. Welt\!`, which is unreadable — and readability is the
|
||||
* whole point of exporting to Markdown rather than JSON. Characters that only
|
||||
* matter at the start of a line are handled by `escapeLineStart` instead.
|
||||
*/
|
||||
const escapeText = (s: string) => s.replace(/([\\`*_[\]|])/g, "\\$1");
|
||||
|
||||
/** `- x`, `# x`, `1. x` and friends turn a paragraph into a block if left alone. */
|
||||
const escapeLineStart = (line: string) =>
|
||||
line.replace(/^(\s*)([-+>#]|\d+\.)(\s)/, (_m, pad, marker, space) => `${pad}\\${marker}${space}`);
|
||||
|
||||
function inline(nodes: DocNode[] | undefined, o: MarkdownOptions): string {
|
||||
if (!Array.isArray(nodes)) return "";
|
||||
return nodes.map((n) => inlineNode(n, o)).join("");
|
||||
}
|
||||
|
||||
function inlineNode(n: DocNode, o: MarkdownOptions): string {
|
||||
if (n.type === "hardBreak") return " \n";
|
||||
|
||||
if (n.type === "noteLink") {
|
||||
const label = n.attrs?.label || "Untitled";
|
||||
const target = o.notePath?.(n.attrs?.noteId);
|
||||
// A link to a note that was not exported would be a dead file reference,
|
||||
// so it degrades to the plain name it points at.
|
||||
return target ? `[${escapeText(label)}](${target})` : escapeText(label);
|
||||
}
|
||||
|
||||
if (n.type === "image") {
|
||||
const alt = escapeText(n.attrs?.alt || "");
|
||||
const id = n.attrs?.attachmentId;
|
||||
const src = id ? o.attachmentPath?.(id) : n.attrs?.src;
|
||||
return src ? `` : `![${alt}]()`;
|
||||
}
|
||||
|
||||
if (n.type !== "text" || !n.text) return "";
|
||||
|
||||
let out = escapeText(n.text);
|
||||
// Applied innermost-first so `**_text_**` nests the way the marks were stored.
|
||||
for (const mark of (n as any).marks || []) {
|
||||
switch (mark.type) {
|
||||
case "bold":
|
||||
out = `**${out}**`;
|
||||
break;
|
||||
case "italic":
|
||||
out = `*${out}*`;
|
||||
break;
|
||||
case "strike":
|
||||
out = `~~${out}~~`;
|
||||
break;
|
||||
case "code":
|
||||
// Backslash escapes are not honoured inside code spans.
|
||||
out = `\`${n.text}\``;
|
||||
break;
|
||||
case "underline":
|
||||
// Markdown has no underline; HTML is the least surprising stand-in.
|
||||
out = `<u>${out}</u>`;
|
||||
break;
|
||||
case "link":
|
||||
out = `[${out}](${mark.attrs?.href || ""})`;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── blocks ── */
|
||||
|
||||
function block(n: DocNode, o: MarkdownOptions, indent = ""): string[] {
|
||||
switch (n.type) {
|
||||
case "paragraph": {
|
||||
const text = inline(n.content, o);
|
||||
return text.trim() ? [escapeLineStart(indent + text)] : [""];
|
||||
}
|
||||
|
||||
case "heading": {
|
||||
const level = Math.min(6, Math.max(1, Number(n.attrs?.level) || 1));
|
||||
return [`${"#".repeat(level)} ${inline(n.content, o)}`];
|
||||
}
|
||||
|
||||
case "blockquote":
|
||||
return children(n, o)
|
||||
.join("\n")
|
||||
.split("\n")
|
||||
.map((l) => (l ? `> ${l}` : ">"));
|
||||
|
||||
case "codeBlock": {
|
||||
const lang = n.attrs?.language || "";
|
||||
const body = (n.content || []).map((c) => c.text || "").join("");
|
||||
return ["```" + lang, ...body.split("\n"), "```"];
|
||||
}
|
||||
|
||||
case "horizontalRule":
|
||||
return ["---"];
|
||||
|
||||
case "bulletList":
|
||||
return list(n, o, indent, () => "- ");
|
||||
|
||||
case "orderedList": {
|
||||
const start = Number(n.attrs?.start) || 1;
|
||||
return list(n, o, indent, (i) => `${start + i}. `);
|
||||
}
|
||||
|
||||
case "taskList":
|
||||
return list(n, o, indent, (_i, item) => `- [${item.attrs?.checked ? "x" : " "}] `);
|
||||
|
||||
case "table":
|
||||
return table(n, o);
|
||||
|
||||
case "image":
|
||||
case "noteLink":
|
||||
return [indent + inlineNode(n, o)];
|
||||
|
||||
default:
|
||||
// An unknown node still contributes its children rather than vanishing.
|
||||
return children(n, o);
|
||||
}
|
||||
}
|
||||
|
||||
function children(n: DocNode, o: MarkdownOptions, indent = ""): string[] {
|
||||
return (n.content || []).flatMap((c) => block(c, o, indent));
|
||||
}
|
||||
|
||||
/** Renders list items, indenting anything nested inside them. */
|
||||
function list(
|
||||
n: DocNode,
|
||||
o: MarkdownOptions,
|
||||
indent: string,
|
||||
marker: (index: number, item: DocNode) => string
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
(n.content || []).forEach((item, i) => {
|
||||
const bullet = marker(i, item);
|
||||
const lines = (item.content || []).flatMap((c) => block(c, o, ""));
|
||||
const [first = "", ...rest] = lines.filter((l, idx) => l !== "" || idx > 0);
|
||||
out.push(indent + bullet + first.trim());
|
||||
// Continuation lines line up under the text, not under the marker.
|
||||
const pad = indent + " ".repeat(bullet.length);
|
||||
for (const line of rest) out.push(line ? pad + line.trim() : "");
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function table(n: DocNode, o: MarkdownOptions): string[] {
|
||||
const rows = (n.content || []).map((row) =>
|
||||
(row.content || []).map((cell) =>
|
||||
// A cell may hold several paragraphs; Markdown tables are one line per row.
|
||||
children(cell, o)
|
||||
// Pipes are already escaped by escapeText; a table row just needs the
|
||||
// cell flattened to a single line.
|
||||
.join(" ")
|
||||
.replace(/\s*\n\s*/g, " ")
|
||||
.trim()
|
||||
)
|
||||
);
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const width = Math.max(...rows.map((r) => r.length));
|
||||
const pad = (r: string[]) => [...r, ...Array(width - r.length).fill("")];
|
||||
const line = (r: string[]) => `| ${pad(r).join(" | ")} |`;
|
||||
|
||||
const headerIsReal = (n.content?.[0]?.content || []).some((c) => c.type === "tableHeader");
|
||||
const [first, ...rest] = rows;
|
||||
return headerIsReal
|
||||
? [line(first!), `|${" --- |".repeat(width)}`, ...rest.map(line)]
|
||||
: // Markdown has no headerless table; an empty header row keeps it a table.
|
||||
[`|${" |".repeat(width)}`, `|${" --- |".repeat(width)}`, ...rows.map(line)];
|
||||
}
|
||||
|
||||
/* ── entry point ── */
|
||||
|
||||
export function noteToMarkdown(doc: DocNode, options: MarkdownOptions = {}): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (options.title) {
|
||||
lines.push(`# ${options.title}`, "");
|
||||
if (options.updatedAt) {
|
||||
lines.push(`*${new Date(options.updatedAt).toLocaleString("de-DE")}*`, "");
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(...children(doc, options));
|
||||
|
||||
if (options.hasDrawing) {
|
||||
lines.push("", "> ✏️ Diese Notiz enthält eine Zeichnung, die Markdown nicht abbilden kann.");
|
||||
lines.push("> Sie liegt als PNG neben der Datei.");
|
||||
}
|
||||
|
||||
// Collapse runs of blank lines; the block rules emit them generously.
|
||||
return lines
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Filename-safe version of a note title. */
|
||||
export function safeFilename(title: string, fallback = "Notiz"): string {
|
||||
const cleaned = (title || "")
|
||||
.replace(/[/\\?%*:|"<>]/g, "-")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 80);
|
||||
return cleaned || fallback;
|
||||
}
|
||||
Reference in new issue
Block a user