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
@@ -3,7 +3,7 @@ import * as sync from "../syncEngine";
|
||||
import { I } from "./icons.jsx";
|
||||
|
||||
/* ═══════ ACCOUNT MENU ═══════ */
|
||||
export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin, dark }) {
|
||||
export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin, onExportAll, dark }) {
|
||||
const [tab, setTab] = useState("profile"); // profile | username | password
|
||||
const [displayName, setDisplayName] = useState(user?.displayName || "");
|
||||
const [newUsername, setNewUsername] = useState(user?.username || "");
|
||||
@@ -367,6 +367,27 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: "0 22px 18px" }}>
|
||||
<button
|
||||
onClick={onExportAll}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
color: T.muted,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
justifyContent: "center",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{I.export} Alle Notizen exportieren
|
||||
</button>
|
||||
{user?.isAdmin && (
|
||||
<button
|
||||
onClick={onOpenAdmin}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { exportNoteAsMarkdown, exportNotesAsPdf, exportNotesAsZip } from "../export/exportNotes";
|
||||
import { I } from "./icons.jsx";
|
||||
import { FONT_SERIF, FONT_UI } from "./theme";
|
||||
|
||||
/**
|
||||
* Export as Markdown or PDF — used for a single note, a notebook, or everything.
|
||||
*
|
||||
* The formats are deliberately both offered rather than one being chosen for
|
||||
* you: Markdown keeps the notes editable somewhere else, PDF keeps them looking
|
||||
* the way they look here. Drawings only survive in PDF and as a PNG beside the
|
||||
* Markdown, which the dialog says out loud rather than leaving to be discovered.
|
||||
*/
|
||||
export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [progress, setProgress] = useState(null);
|
||||
const [err, setErr] = useState("");
|
||||
const [done, setDone] = useState("");
|
||||
|
||||
const T = useMemo(
|
||||
() => ({
|
||||
bg: dark ? "#161b22" : "#fff",
|
||||
surface: dark ? "#0d1117" : "#f6f8fa",
|
||||
border: dark ? "#30363d" : "#d0d7de",
|
||||
text: dark ? "#e6edf3" : "#1f2328",
|
||||
muted: dark ? "#8b949e" : "#656d76",
|
||||
faint: dark ? "#484f58" : "#afb8c1",
|
||||
accent: dark ? "#388bfd" : "#0969da",
|
||||
danger: dark ? "#f85149" : "#cf222e",
|
||||
success: dark ? "#3fb950" : "#1a7f37",
|
||||
}),
|
||||
[dark]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => e.key === "Escape" && !busy && onClose();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [busy, onClose]);
|
||||
|
||||
const single = notes.length === 1;
|
||||
|
||||
const run = async (kind) => {
|
||||
setErr("");
|
||||
setDone("");
|
||||
setBusy(kind);
|
||||
setProgress(null);
|
||||
try {
|
||||
if (kind === "markdown") {
|
||||
if (single) await exportNoteAsMarkdown(notes[0]);
|
||||
else await exportNotesAsZip({ label, notes, notebooks, onProgress: setProgress });
|
||||
} else {
|
||||
await exportNotesAsPdf(notes, label, setProgress);
|
||||
}
|
||||
setDone(
|
||||
kind === "markdown"
|
||||
? single
|
||||
? "Datei wurde heruntergeladen."
|
||||
: "Archiv wurde heruntergeladen."
|
||||
: "Druckansicht war geöffnet — wähle dort „Als PDF sichern“."
|
||||
);
|
||||
} catch (e) {
|
||||
setErr(e?.message || "Export fehlgeschlagen");
|
||||
}
|
||||
setBusy(null);
|
||||
setProgress(null);
|
||||
};
|
||||
|
||||
const option = (kind, title, lines) => (
|
||||
<button
|
||||
onClick={() => run(kind)}
|
||||
disabled={!!busy}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: 4,
|
||||
width: "100%",
|
||||
padding: "14px 16px",
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: T.surface,
|
||||
cursor: busy ? "default" : "pointer",
|
||||
textAlign: "left",
|
||||
opacity: busy && busy !== kind ? 0.45 : 1,
|
||||
marginBottom: 10,
|
||||
fontFamily: FONT_UI,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: T.text }}>{busy === kind ? "Läuft…" : title}</span>
|
||||
{lines.map((l, i) => (
|
||||
<span key={i} style={{ fontSize: 12, color: T.muted, lineHeight: 1.5 }}>
|
||||
{l}
|
||||
</span>
|
||||
))}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 2200,
|
||||
backdropFilter: "blur(6px)",
|
||||
fontFamily: FONT_UI,
|
||||
}}
|
||||
onClick={() => !busy && onClose()}
|
||||
>
|
||||
<div
|
||||
data-export-dialog
|
||||
style={{
|
||||
background: T.bg,
|
||||
borderRadius: 20,
|
||||
width: 460,
|
||||
maxWidth: "92vw",
|
||||
border: `1px solid ${T.border}`,
|
||||
boxShadow: "0 24px 48px rgba(0,0,0,0.3)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "18px 22px",
|
||||
borderBottom: `1px solid ${T.border}`,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 17, fontWeight: 600, color: T.text, fontFamily: FONT_SERIF }}>
|
||||
Exportieren
|
||||
</h3>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginTop: 2 }}>
|
||||
{single ? label : `${label} · ${notes.length} Notizen`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => !busy && onClose()}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: busy ? "default" : "pointer",
|
||||
color: T.muted,
|
||||
}}
|
||||
>
|
||||
{I.x}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "16px 22px 8px" }}>
|
||||
{option("markdown", single ? "Als Markdown" : "Als Markdown-Archiv (ZIP)", [
|
||||
single
|
||||
? "Eine .md-Datei — Bilder und Zeichnung kommen als ZIP mit, falls vorhanden."
|
||||
: "Je Notiz eine .md-Datei, dazu Bilder, Zeichnungen und ein Inhaltsverzeichnis.",
|
||||
"Bleibt anderswo bearbeitbar. Tabellen und Aufgabenhaken bleiben erhalten.",
|
||||
])}
|
||||
{option("pdf", "Als PDF", [
|
||||
"Öffnet den Druckdialog — dort „Als PDF sichern“ wählen.",
|
||||
"Zeichnungen sind hier mit im Dokument.",
|
||||
])}
|
||||
</div>
|
||||
|
||||
{(progress || err || done) && (
|
||||
<div style={{ padding: "0 22px 8px" }}>
|
||||
{progress && (
|
||||
<div style={{ fontSize: 12, color: T.muted }}>
|
||||
{progress.done}/{progress.total} — {progress.label}
|
||||
<div
|
||||
style={{
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
background: T.border,
|
||||
marginTop: 6,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${Math.round((progress.done / Math.max(1, progress.total)) * 100)}%`,
|
||||
background: T.accent,
|
||||
transition: "width .2s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{err && <div style={{ fontSize: 12, color: T.danger }}>{err}</div>}
|
||||
{done && (
|
||||
<div data-export-done style={{ fontSize: 12, color: T.success }}>
|
||||
{done}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ padding: "8px 22px 18px", fontSize: 11, color: T.faint, lineHeight: 1.5 }}>
|
||||
Alles läuft im Browser — es geht nichts an einen fremden Dienst.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -453,6 +453,22 @@ export const I = {
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
),
|
||||
export: (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="16 6 12 2 8 6" />
|
||||
<line x1="12" y1="2" x2="12" y2="15" />
|
||||
</svg>
|
||||
),
|
||||
users: (
|
||||
<svg
|
||||
width="14"
|
||||
|
||||
Reference in new issue
Block a user