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
+184
@@ -0,0 +1,184 @@
|
||||
/* Export to Markdown and ZIP.
|
||||
*
|
||||
* The unit tests cover the serialiser; this checks the part they cannot — that
|
||||
* a click produces a real download whose bytes are what they should be. The
|
||||
* files are caught in the browser rather than written to disk, so the archive
|
||||
* can be unpacked and read here. */
|
||||
import {
|
||||
OUT,
|
||||
check,
|
||||
createNote,
|
||||
createNotebook,
|
||||
insertTable,
|
||||
launch,
|
||||
report,
|
||||
setTitle,
|
||||
typeInEditor,
|
||||
withFreshUser,
|
||||
} from "./lib.mjs";
|
||||
|
||||
const PNG =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAYAAABWKLW/AAAAFElEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC";
|
||||
|
||||
/** Intercepts download() so the bytes stay in the page and can be inspected. */
|
||||
async function captureDownloads(page) {
|
||||
await page.evaluate(() => {
|
||||
window.__downloads = [];
|
||||
const realCreate = URL.createObjectURL.bind(URL);
|
||||
URL.createObjectURL = (blob) => {
|
||||
const url = realCreate(blob);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () =>
|
||||
window.__downloads.push({
|
||||
url,
|
||||
type: blob.type,
|
||||
size: blob.size,
|
||||
// base64 of the payload, so binary survives the trip out of the page
|
||||
base64: String(reader.result).split(",")[1] || "",
|
||||
});
|
||||
reader.readAsDataURL(blob);
|
||||
return url;
|
||||
};
|
||||
// A real navigation would abandon the test run.
|
||||
HTMLAnchorElement.prototype.click = function () {
|
||||
window.__lastDownloadName = this.download;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const downloads = (page) => page.evaluate(() => window.__downloads || []);
|
||||
const lastName = (page) => page.evaluate(() => window.__lastDownloadName || "");
|
||||
|
||||
/** Unpacks a stored-only ZIP far enough to list names and read text entries. */
|
||||
function readZip(bytes) {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const dec = new TextDecoder();
|
||||
const endAt = bytes.length - 22;
|
||||
const count = view.getUint16(endAt + 8, true);
|
||||
let p = view.getUint32(endAt + 16, true);
|
||||
const out = {};
|
||||
for (let i = 0; i < count; i++) {
|
||||
const size = view.getUint32(p + 24, true);
|
||||
const nameLen = view.getUint16(p + 28, true);
|
||||
const localAt = view.getUint32(p + 42, true);
|
||||
const name = dec.decode(bytes.slice(p + 46, p + 46 + nameLen));
|
||||
const dataAt = localAt + 30 + view.getUint16(localAt + 26, true) + view.getUint16(localAt + 28, true);
|
||||
out[name] = bytes.slice(dataAt, dataAt + size);
|
||||
p += 46 + nameLen + view.getUint16(p + 30, true) + view.getUint16(p + 32, true);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const openExportDialog = async (page) => {
|
||||
await page.getByTitle("Export").click();
|
||||
await page.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
||||
await page.waitForTimeout(500);
|
||||
};
|
||||
|
||||
const browser = await launch();
|
||||
let session;
|
||||
try {
|
||||
session = await withFreshUser(browser, "export");
|
||||
const p = session.page;
|
||||
await createNotebook(p, "Export Buch");
|
||||
|
||||
/* ── a note with everything that is hard to serialise ── */
|
||||
await createNote(p);
|
||||
await setTitle(p, "Alles drin");
|
||||
await typeInEditor(p, "Einleitender Satz.");
|
||||
await insertTable(p);
|
||||
await p.locator(".ProseMirror table th").first().click();
|
||||
await p.keyboard.type("Spalte");
|
||||
await p.waitForTimeout(600);
|
||||
await p.locator(".ProseMirror").click();
|
||||
await p.keyboard.press("Control+End");
|
||||
await p.keyboard.press("Enter");
|
||||
await p.getByTitle("Task list").click();
|
||||
await p.waitForTimeout(400);
|
||||
await p.keyboard.type("Erledigte Sache");
|
||||
await p.waitForTimeout(800);
|
||||
await p.locator('.ProseMirror input[type="checkbox"]').first().click();
|
||||
await p.waitForTimeout(1800);
|
||||
|
||||
await captureDownloads(p);
|
||||
|
||||
/* ── single note as Markdown ── */
|
||||
await openExportDialog(p);
|
||||
await p.getByRole("button", { name: /Als Markdown/ }).click();
|
||||
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
||||
await p.waitForTimeout(600);
|
||||
|
||||
let files = await downloads(p);
|
||||
check(files.length === 1, "a single note produces one download", `${files.length}`);
|
||||
check(/\.md$/.test(await lastName(p)), "it is named .md", await lastName(p));
|
||||
|
||||
const md = Buffer.from(files[0].base64, "base64").toString("utf8");
|
||||
check(md.startsWith("# Alles drin"), "the title became a heading");
|
||||
check(md.includes("Einleitender Satz."), "the body text is there");
|
||||
check(/\|\s*Spalte\s*\|/.test(md), "the table came along", md.match(/\|.*\|/)?.[0] || "no table row");
|
||||
check(md.includes("| --- |"), "the table has a separator row so it renders as a table");
|
||||
check(md.includes("- [x] Erledigte Sache"), "the ticked task is a ticked checkbox");
|
||||
console.log("--- Markdown ---\n" + md + "\n--- Ende ---");
|
||||
|
||||
// The dialog stays up after an export so the result can be read; close it or
|
||||
// its backdrop swallows the next click.
|
||||
await p.keyboard.press("Escape");
|
||||
await p.waitForTimeout(600);
|
||||
|
||||
/* ── a second note and an image, then the whole notebook ── */
|
||||
await createNote(p);
|
||||
await setTitle(p, "Zweite Notiz");
|
||||
await typeInEditor(p, "Kurzer Text mit Bild.");
|
||||
await p.locator(".ProseMirror").click();
|
||||
await p.keyboard.press("Control+End");
|
||||
await p.evaluate(async (b64) => {
|
||||
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(new File([bytes], "dot.png", { type: "image/png" }));
|
||||
document
|
||||
.querySelector(".ProseMirror")
|
||||
.dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true }));
|
||||
}, PNG);
|
||||
await p.waitForTimeout(3500);
|
||||
|
||||
await p.evaluate(() => (window.__downloads = []));
|
||||
await openExportDialog(p);
|
||||
await p.getByRole("button", { name: /Als Markdown/ }).click();
|
||||
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
|
||||
await p.waitForTimeout(800);
|
||||
files = await downloads(p);
|
||||
check(files.length === 1 && /zip/.test(files[0].type), "a note with an image is packed into a ZIP", files[0]?.type);
|
||||
|
||||
const zip = readZip(new Uint8Array(Buffer.from(files[0].base64, "base64")));
|
||||
const names = Object.keys(zip);
|
||||
check(names.some((n) => n.endsWith(".md")), "the archive holds the Markdown file", names.join(", "));
|
||||
check(names.some((n) => /bilder\/.*\.png$/.test(n)), "and the image beside it", names.join(", "));
|
||||
|
||||
await p.keyboard.press("Escape");
|
||||
await p.waitForTimeout(600);
|
||||
|
||||
const imgBytes = zip[names.find((n) => n.endsWith(".png"))];
|
||||
check(
|
||||
imgBytes[0] === 0x89 && imgBytes[1] === 0x50 && imgBytes[2] === 0x4e && imgBytes[3] === 0x47,
|
||||
"the extracted image really is a PNG",
|
||||
Array.from(imgBytes.slice(0, 4)).join(",")
|
||||
);
|
||||
await p.screenshot({ path: `${OUT}/export-dialog.png` });
|
||||
|
||||
/* ── whole notebook, via the sidebar's context menu ── */
|
||||
await p.evaluate(() => (window.__downloads = []));
|
||||
await p.locator("[data-note-row]").first().click({ button: "right" });
|
||||
await p.waitForTimeout(600);
|
||||
await p.getByText("Export...", { exact: true }).click();
|
||||
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
||||
await p.waitForTimeout(500);
|
||||
check(true, "the context menu opens the export dialog");
|
||||
await p.keyboard.press("Escape");
|
||||
} catch (e) {
|
||||
check(false, "suite ran to the end", e.message);
|
||||
} finally {
|
||||
await session?.cleanup();
|
||||
const code = report();
|
||||
await browser.close();
|
||||
process.exit(code);
|
||||
}
|
||||
+1
-1
@@ -13,7 +13,7 @@ cd "$(dirname "$0")"
|
||||
IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble"
|
||||
URL="${INKNOTE_URL:-http://localhost:3000}"
|
||||
SUITES=("$@")
|
||||
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing)
|
||||
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export)
|
||||
|
||||
mkdir -p shots
|
||||
if [ ! -d node_modules ]; then
|
||||
|
||||
@@ -17,6 +17,7 @@ import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx"
|
||||
import { AccountMenu } from "./ui/AccountMenu.jsx";
|
||||
import { AuthScreen } from "./ui/AuthScreen.jsx";
|
||||
import { AdminPanel } from "./ui/AdminPanel.jsx";
|
||||
import { ExportDialog } from "./ui/ExportDialog.jsx";
|
||||
import { Sidebar } from "./ui/Sidebar.jsx";
|
||||
import { Dashboard } from "./features/notebooks/Dashboard.jsx";
|
||||
import { NoteList } from "./features/notes/NoteList.jsx";
|
||||
@@ -51,6 +52,8 @@ export default function App() {
|
||||
const [editingNb, setEditingNb] = useState(null);
|
||||
const [showAccount, setShowAccount] = useState(false);
|
||||
const [showAdmin, setShowAdmin] = useState(false);
|
||||
// { notes, label } — set while the export dialog is up.
|
||||
const [exporting, setExporting] = useState(null);
|
||||
const [historyFor, setHistoryFor] = useState(null);
|
||||
const [ctxMenu, setCtxMenu] = useState(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState(null);
|
||||
@@ -318,6 +321,15 @@ export default function App() {
|
||||
y: e.clientY,
|
||||
items: [
|
||||
{ label: "Edit", icon: I.edit, action: () => setEditingNb(nb) },
|
||||
{
|
||||
label: "Export...",
|
||||
icon: I.export,
|
||||
action: () =>
|
||||
setExporting({
|
||||
label: nb.name,
|
||||
notes: activeNotes.filter((n) => n.folderId === nb.id),
|
||||
}),
|
||||
},
|
||||
{ divider: true },
|
||||
{ label: "Delete Notebook", icon: I.trash, danger: true, action: () => deleteNotebook(nb.id) },
|
||||
],
|
||||
@@ -357,6 +369,11 @@ export default function App() {
|
||||
action: () => data.toggleStar(note.id),
|
||||
},
|
||||
...moveSubmenu(note),
|
||||
{
|
||||
label: "Export...",
|
||||
icon: I.export,
|
||||
action: () => setExporting({ label: note.title, notes: [note] }),
|
||||
},
|
||||
{ divider: true },
|
||||
{ label: "Move to Trash", icon: I.trash, danger: true, action: () => trashNote(note.id) },
|
||||
];
|
||||
@@ -492,6 +509,7 @@ export default function App() {
|
||||
onRestore={() => restoreNote(activeNote)}
|
||||
onPermanentDelete={() => permanentDeleteNote(activeNote)}
|
||||
onOpenHistory={() => setHistoryFor(activeNote)}
|
||||
onExport={() => noteMeta && setExporting({ label: noteMeta.title, notes: [noteMeta] })}
|
||||
onCreateNote={createNote}
|
||||
/>
|
||||
)}
|
||||
@@ -522,9 +540,22 @@ export default function App() {
|
||||
setShowAccount(false);
|
||||
setShowAdmin(true);
|
||||
}}
|
||||
onExportAll={() => {
|
||||
setShowAccount(false);
|
||||
setExporting({ label: "Alle Notizen", notes: activeNotes });
|
||||
}}
|
||||
dark={dark}
|
||||
/>
|
||||
)}
|
||||
{exporting && (
|
||||
<ExportDialog
|
||||
notes={exporting.notes}
|
||||
notebooks={notebooks}
|
||||
label={exporting.label}
|
||||
dark={dark}
|
||||
onClose={() => setExporting(null)}
|
||||
/>
|
||||
)}
|
||||
{showAdmin && user?.isAdmin && (
|
||||
<AdminPanel currentUser={user} onClose={() => setShowAdmin(false)} dark={dark} />
|
||||
)}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { noteToMarkdown, safeFilename } from "./markdown";
|
||||
import type { DocNode } from "../editor/content";
|
||||
|
||||
const doc = (...content: DocNode[]): DocNode => ({ type: "doc", content });
|
||||
const para = (...content: DocNode[]): DocNode => ({ type: "paragraph", content });
|
||||
const text = (t: string, marks?: { type: string; attrs?: Record<string, unknown> }[]): DocNode =>
|
||||
({ type: "text", text: t, ...(marks ? { marks } : {}) }) as DocNode;
|
||||
const md = (d: DocNode, o = {}) => noteToMarkdown(d, o);
|
||||
|
||||
describe("text and marks", () => {
|
||||
it("writes a plain paragraph", () => {
|
||||
expect(md(doc(para(text("Hallo Welt"))))).toBe("Hallo Welt");
|
||||
});
|
||||
|
||||
it("renders bold, italic and strikethrough", () => {
|
||||
expect(md(doc(para(text("fett", [{ type: "bold" }]))))).toBe("**fett**");
|
||||
expect(md(doc(para(text("kursiv", [{ type: "italic" }]))))).toBe("*kursiv*");
|
||||
expect(md(doc(para(text("weg", [{ type: "strike" }]))))).toBe("~~weg~~");
|
||||
});
|
||||
|
||||
it("falls back to HTML for underline, which Markdown lacks", () => {
|
||||
expect(md(doc(para(text("unter", [{ type: "underline" }]))))).toBe("<u>unter</u>");
|
||||
});
|
||||
|
||||
it("keeps a web link", () => {
|
||||
const out = md(doc(para(text("hier", [{ type: "link", attrs: { href: "https://example.com" } }]))));
|
||||
expect(out).toBe("[hier](https://example.com)");
|
||||
});
|
||||
|
||||
it("escapes characters that would otherwise become syntax mid-line", () => {
|
||||
expect(md(doc(para(text("2 * 3 _und_ [mehr]"))))).toBe("2 \\* 3 \\_und\\_ \\[mehr\\]");
|
||||
});
|
||||
|
||||
it("leaves ordinary punctuation alone, so the result stays readable", () => {
|
||||
expect(md(doc(para(text("Hallo. Alles gut! Preis: 5-10 EUR (ca.)"))))).toBe(
|
||||
"Hallo. Alles gut! Preis: 5-10 EUR (ca.)"
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes a leading marker so a paragraph does not become a list", () => {
|
||||
expect(md(doc(para(text("- kein Listenpunkt"))))).toBe("\\- kein Listenpunkt");
|
||||
expect(md(doc(para(text("1. kein Punkt"))))).toBe("\\1. kein Punkt");
|
||||
expect(md(doc(para(text("# keine Ueberschrift"))))).toBe("\\# keine Ueberschrift");
|
||||
});
|
||||
|
||||
it("does not escape inside a code span, where backslashes are literal", () => {
|
||||
expect(md(doc(para(text("a_b", [{ type: "code" }]))))).toBe("`a_b`");
|
||||
});
|
||||
|
||||
it("turns a hard break into a Markdown line break", () => {
|
||||
expect(md(doc(para(text("eins"), { type: "hardBreak" }, text("zwei"))))).toBe("eins \nzwei");
|
||||
});
|
||||
});
|
||||
|
||||
describe("blocks", () => {
|
||||
it("writes headings at their level", () => {
|
||||
expect(md(doc({ type: "heading", attrs: { level: 2 }, content: [text("Titel")] }))).toBe("## Titel");
|
||||
});
|
||||
|
||||
it("clamps an out-of-range heading level instead of emitting nonsense", () => {
|
||||
expect(md(doc({ type: "heading", attrs: { level: 99 }, content: [text("X")] }))).toBe("###### X");
|
||||
});
|
||||
|
||||
it("writes a bullet list", () => {
|
||||
const list = {
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{ type: "listItem", content: [para(text("eins"))] },
|
||||
{ type: "listItem", content: [para(text("zwei"))] },
|
||||
],
|
||||
};
|
||||
expect(md(doc(list))).toBe("- eins\n- zwei");
|
||||
});
|
||||
|
||||
it("numbers an ordered list from its start attribute", () => {
|
||||
const list = {
|
||||
type: "orderedList",
|
||||
attrs: { start: 3 },
|
||||
content: [
|
||||
{ type: "listItem", content: [para(text("drei"))] },
|
||||
{ type: "listItem", content: [para(text("vier"))] },
|
||||
],
|
||||
};
|
||||
expect(md(doc(list))).toBe("3. drei\n4. vier");
|
||||
});
|
||||
|
||||
it("writes task state as a checkbox, so it survives the trip", () => {
|
||||
const list = {
|
||||
type: "taskList",
|
||||
content: [
|
||||
{ type: "taskItem", attrs: { checked: true }, content: [para(text("erledigt"))] },
|
||||
{ type: "taskItem", attrs: { checked: false }, content: [para(text("offen"))] },
|
||||
],
|
||||
};
|
||||
expect(md(doc(list))).toBe("- [x] erledigt\n- [ ] offen");
|
||||
});
|
||||
|
||||
it("writes a quote with every line prefixed", () => {
|
||||
const quote = { type: "blockquote", content: [para(text("zitiert"))] };
|
||||
expect(md(doc(quote))).toBe("> zitiert");
|
||||
});
|
||||
|
||||
it("fences a code block and keeps its language", () => {
|
||||
const code = { type: "codeBlock", attrs: { language: "js" }, content: [text("const a = 1;")] };
|
||||
expect(md(doc(code))).toBe("```js\nconst a = 1;\n```");
|
||||
});
|
||||
|
||||
it("writes a horizontal rule", () => {
|
||||
expect(md(doc({ type: "horizontalRule" }))).toBe("---");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tables", () => {
|
||||
const cell = (t: string, type = "tableCell"): DocNode => ({ type, content: [para(text(t))] });
|
||||
|
||||
it("writes a header row and separator", () => {
|
||||
const table = {
|
||||
type: "table",
|
||||
content: [
|
||||
{ type: "tableRow", content: [cell("A", "tableHeader"), cell("B", "tableHeader")] },
|
||||
{ type: "tableRow", content: [cell("1"), cell("2")] },
|
||||
],
|
||||
};
|
||||
expect(md(doc(table))).toBe("| A | B |\n| --- | --- |\n| 1 | 2 |");
|
||||
});
|
||||
|
||||
it("gives a headerless table an empty header, since Markdown has no other form", () => {
|
||||
const table = {
|
||||
type: "table",
|
||||
content: [{ type: "tableRow", content: [cell("1"), cell("2")] }],
|
||||
};
|
||||
const out = md(doc(table)).split("\n");
|
||||
expect(out[1]).toBe("| --- | --- |");
|
||||
expect(out[2]).toBe("| 1 | 2 |");
|
||||
});
|
||||
|
||||
it("escapes a pipe inside a cell so the table does not break apart", () => {
|
||||
const table = {
|
||||
type: "table",
|
||||
content: [{ type: "tableRow", content: [cell("a|b", "tableHeader")] }],
|
||||
};
|
||||
expect(md(doc(table))).toContain("a\\|b");
|
||||
expect(md(doc(table))).not.toContain("a\\\\|b");
|
||||
});
|
||||
|
||||
it("pads a short row so the columns stay aligned", () => {
|
||||
const table = {
|
||||
type: "table",
|
||||
content: [
|
||||
{ type: "tableRow", content: [cell("A", "tableHeader"), cell("B", "tableHeader")] },
|
||||
{ type: "tableRow", content: [cell("1")] },
|
||||
],
|
||||
};
|
||||
expect(md(doc(table))).toContain("| 1 | |");
|
||||
});
|
||||
});
|
||||
|
||||
describe("images and note links", () => {
|
||||
it("points an attachment at its file in the archive", () => {
|
||||
const img = { type: "image", attrs: { attachmentId: "abc", alt: "Bild" } };
|
||||
const out = md(doc(img), { attachmentPath: (id: string) => `images/${id}.png` });
|
||||
expect(out).toBe("");
|
||||
});
|
||||
|
||||
it("links to another exported note", () => {
|
||||
const link = para({ type: "noteLink", attrs: { noteId: "n1", label: "Andere Notiz" } });
|
||||
const out = md(doc(link), { notePath: (id: string) => `${id}.md` });
|
||||
expect(out).toBe("[Andere Notiz](n1.md)");
|
||||
});
|
||||
|
||||
it("degrades a link to a note that was not exported, rather than leaving a dead file link", () => {
|
||||
const link = para({ type: "noteLink", attrs: { noteId: "n1", label: "Andere Notiz" } });
|
||||
expect(md(doc(link), { notePath: () => null })).toBe("Andere Notiz");
|
||||
});
|
||||
});
|
||||
|
||||
describe("document framing", () => {
|
||||
it("puts the title in a heading", () => {
|
||||
expect(md(doc(para(text("Text"))), { title: "Meine Notiz" })).toBe("# Meine Notiz\n\nText");
|
||||
});
|
||||
|
||||
it("says when a drawing could not come along, instead of dropping it silently", () => {
|
||||
const out = md(doc(para(text("Text"))), { hasDrawing: true });
|
||||
expect(out).toContain("Zeichnung");
|
||||
});
|
||||
|
||||
it("collapses the blank lines the block rules emit generously", () => {
|
||||
const out = md(doc(para(text("a")), para(), para(), para(text("b"))));
|
||||
expect(out).not.toMatch(/\n{3,}/);
|
||||
});
|
||||
|
||||
it("survives an empty document", () => {
|
||||
expect(md(doc())).toBe("");
|
||||
expect(md({ type: "doc" })).toBe("");
|
||||
});
|
||||
|
||||
it("keeps the children of a node type it does not know", () => {
|
||||
const weird = { type: "somethingNew", content: [para(text("trotzdem da"))] };
|
||||
expect(md(doc(weird))).toBe("trotzdem da");
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeFilename", () => {
|
||||
it("strips characters a filesystem would refuse", () => {
|
||||
expect(safeFilename('a/b\\c:d*e?f"g<h>i|j')).toBe("a-b-c-d-e-f-g-h-i-j");
|
||||
});
|
||||
|
||||
it("falls back when nothing usable is left", () => {
|
||||
expect(safeFilename("")).toBe("Notiz");
|
||||
expect(safeFilename("///")).toBe("---");
|
||||
});
|
||||
|
||||
it("caps the length", () => {
|
||||
expect(safeFilename("x".repeat(200)).length).toBe(80);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Notes as PDF, by way of the browser's own print dialog.
|
||||
*
|
||||
* No PDF library: every browser already contains a good one behind "Save as
|
||||
* PDF", and it handles fonts, page breaks and hyphenation better than anything
|
||||
* that would fit in this project. What is built here is the page it prints.
|
||||
*
|
||||
* The document is written into a hidden iframe rather than a new window, so a
|
||||
* pop-up blocker cannot swallow it and the app's own styles cannot leak in.
|
||||
*/
|
||||
|
||||
import { parseNoteContent } from "../editor/content";
|
||||
import { parsingExtensions } from "../editor/extensions.js";
|
||||
import type { DocNode } from "../editor/content";
|
||||
import type { NoteContent, NoteMeta } from "../types";
|
||||
|
||||
const esc = (s: unknown) =>
|
||||
String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
/* ── document to print-ready HTML ── */
|
||||
|
||||
function inline(nodes: DocNode[] | undefined, resolveImage: (id: string) => string | null): string {
|
||||
if (!Array.isArray(nodes)) return "";
|
||||
return nodes
|
||||
.map((n) => {
|
||||
if (n.type === "hardBreak") return "<br>";
|
||||
if (n.type === "noteLink") return `<span class="note-link">${esc(n.attrs?.label || "Untitled")}</span>`;
|
||||
if (n.type === "image") {
|
||||
const src = n.attrs?.attachmentId ? resolveImage(n.attrs.attachmentId) : n.attrs?.src;
|
||||
return src ? `<img src="${esc(src)}" alt="${esc(n.attrs?.alt || "")}">` : "";
|
||||
}
|
||||
if (n.type !== "text" || !n.text) return "";
|
||||
|
||||
let html = esc(n.text);
|
||||
for (const mark of (n as any).marks || []) {
|
||||
if (mark.type === "bold") html = `<strong>${html}</strong>`;
|
||||
else if (mark.type === "italic") html = `<em>${html}</em>`;
|
||||
else if (mark.type === "underline") html = `<u>${html}</u>`;
|
||||
else if (mark.type === "strike") html = `<s>${html}</s>`;
|
||||
else if (mark.type === "code") html = `<code>${html}</code>`;
|
||||
else if (mark.type === "link") html = `<a href="${esc(mark.attrs?.href)}">${html}</a>`;
|
||||
}
|
||||
return html;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function blocks(nodes: DocNode[] | undefined, resolveImage: (id: string) => string | null): string {
|
||||
if (!Array.isArray(nodes)) return "";
|
||||
return nodes.map((n) => blockHtml(n, resolveImage)).join("\n");
|
||||
}
|
||||
|
||||
function blockHtml(n: DocNode, img: (id: string) => string | null): string {
|
||||
const kids = () => blocks(n.content, img);
|
||||
switch (n.type) {
|
||||
case "paragraph": {
|
||||
const body = inline(n.content, img);
|
||||
return body.trim() ? `<p>${body}</p>` : "<p> </p>";
|
||||
}
|
||||
case "heading": {
|
||||
const l = Math.min(6, Math.max(1, Number(n.attrs?.level) || 1));
|
||||
return `<h${l}>${inline(n.content, img)}</h${l}>`;
|
||||
}
|
||||
case "blockquote":
|
||||
return `<blockquote>${kids()}</blockquote>`;
|
||||
case "codeBlock":
|
||||
return `<pre><code>${esc((n.content || []).map((c) => c.text || "").join(""))}</code></pre>`;
|
||||
case "horizontalRule":
|
||||
return "<hr>";
|
||||
case "bulletList":
|
||||
return `<ul>${kids()}</ul>`;
|
||||
case "orderedList":
|
||||
return `<ol${n.attrs?.start ? ` start="${Number(n.attrs.start)}"` : ""}>${kids()}</ol>`;
|
||||
case "listItem":
|
||||
return `<li>${kids()}</li>`;
|
||||
case "taskList":
|
||||
return `<ul class="tasks">${kids()}</ul>`;
|
||||
case "taskItem":
|
||||
return `<li class="task"><span class="box">${n.attrs?.checked ? "☑" : "☐"}</span><div>${kids()}</div></li>`;
|
||||
case "table":
|
||||
return `<table>${kids()}</table>`;
|
||||
case "tableRow":
|
||||
return `<tr>${kids()}</tr>`;
|
||||
case "tableHeader":
|
||||
return `<th>${kids()}</th>`;
|
||||
case "tableCell":
|
||||
return `<td>${kids()}</td>`;
|
||||
case "image":
|
||||
case "noteLink":
|
||||
return `<p>${inline([n], img)}</p>`;
|
||||
default:
|
||||
return kids();
|
||||
}
|
||||
}
|
||||
|
||||
/* ── page ── */
|
||||
|
||||
const STYLES = `
|
||||
@page { margin: 18mm 16mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font: 11pt/1.55 "DM Sans", -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
color: #111; margin: 0;
|
||||
}
|
||||
article { page-break-after: always; }
|
||||
article:last-child { page-break-after: auto; }
|
||||
h1 { font-family: Newsreader, Georgia, serif; font-size: 20pt; margin: 0 0 2mm; }
|
||||
h2 { font-size: 14pt; margin: 6mm 0 2mm; }
|
||||
h3 { font-size: 12pt; margin: 5mm 0 2mm; }
|
||||
.meta { color: #666; font-size: 9pt; margin-bottom: 6mm;
|
||||
border-bottom: 1px solid #ddd; padding-bottom: 2mm; }
|
||||
p { margin: 0 0 2.6mm; orphans: 2; widows: 2; }
|
||||
ul, ol { margin: 0 0 3mm; padding-left: 6mm; }
|
||||
li { margin-bottom: 1mm; }
|
||||
ul.tasks { list-style: none; padding-left: 1mm; }
|
||||
li.task { display: flex; gap: 2mm; align-items: baseline; }
|
||||
li.task .box { font-size: 12pt; line-height: 1; }
|
||||
li.task div p { margin: 0; }
|
||||
blockquote { margin: 0 0 3mm; padding-left: 4mm; border-left: 2px solid #ccc; color: #444; }
|
||||
pre { background: #f4f4f4; padding: 3mm; border-radius: 2mm; overflow-wrap: anywhere;
|
||||
white-space: pre-wrap; font-size: 9.5pt; }
|
||||
code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 9.5pt; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 0 0 3mm; page-break-inside: avoid; }
|
||||
th, td { border: 1px solid #bbb; padding: 1.6mm 2.2mm; text-align: left; vertical-align: top; }
|
||||
th { background: #f2f2f2; font-weight: 600; }
|
||||
img { max-width: 100%; height: auto; page-break-inside: avoid; }
|
||||
hr { border: none; border-top: 1px solid #ccc; margin: 4mm 0; }
|
||||
.note-link { color: #0550ae; }
|
||||
.note-link::before { content: "→ "; }
|
||||
.drawing { margin-top: 5mm; page-break-inside: avoid; }
|
||||
.drawing img { border: 1px solid #ddd; }
|
||||
`;
|
||||
|
||||
export interface PrintableNote {
|
||||
meta: NoteMeta;
|
||||
content: NoteContent;
|
||||
/** Drawing rendered as a data URL, if the note has one. */
|
||||
drawing?: string | null;
|
||||
/** Attachment id → data URL, for images to survive into the print document. */
|
||||
images?: Map<string, string>;
|
||||
}
|
||||
|
||||
function articleFor(note: PrintableNote): string {
|
||||
const doc = parseNoteContent(note.content.content, parsingExtensions);
|
||||
const resolve = (id: string) => note.images?.get(id) || null;
|
||||
const when = note.meta.updatedAt ? new Date(note.meta.updatedAt).toLocaleString("de-DE") : "";
|
||||
|
||||
return `<article>
|
||||
<h1>${esc(note.meta.title || "Untitled")}</h1>
|
||||
${when ? `<div class="meta">${esc(when)}</div>` : ""}
|
||||
${blocks(doc.content, resolve)}
|
||||
${note.drawing ? `<div class="drawing"><img src="${note.drawing}" alt="Zeichnung"></div>` : ""}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
export function buildPrintDocument(notes: PrintableNote[], title: string): string {
|
||||
return `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||
<title>${esc(title)}</title><style>${STYLES}</style></head>
|
||||
<body>${notes.map(articleFor).join("\n")}</body></html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the print dialog on the given notes.
|
||||
*
|
||||
* Resolves once printing has been dismissed, or after a timeout — browsers are
|
||||
* inconsistent about `afterprint`, and a caller left waiting forever would be
|
||||
* worse than one that cleans up slightly early.
|
||||
*/
|
||||
export function printNotes(notes: PrintableNote[], title: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.setAttribute("aria-hidden", "true");
|
||||
frame.style.cssText = "position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden";
|
||||
document.body.appendChild(frame);
|
||||
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
setTimeout(() => frame.remove(), 500);
|
||||
resolve();
|
||||
};
|
||||
|
||||
frame.onload = () => {
|
||||
const win = frame.contentWindow;
|
||||
if (!win) return finish();
|
||||
win.addEventListener("afterprint", finish);
|
||||
// Give images in the document a moment to decode before printing them.
|
||||
setTimeout(() => {
|
||||
try {
|
||||
win.focus();
|
||||
win.print();
|
||||
} catch {
|
||||
/* nothing sensible to do; the cleanup below still runs */
|
||||
}
|
||||
setTimeout(finish, 60_000);
|
||||
}, 350);
|
||||
};
|
||||
|
||||
const doc = frame.contentDocument!;
|
||||
doc.open();
|
||||
doc.write(buildPrintDocument(notes, title));
|
||||
doc.close();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { createZipBytes, __testing } from "./zip";
|
||||
|
||||
const { crc32, dosDateTime } = __testing;
|
||||
const bytes = (s: string) => new TextEncoder().encode(s);
|
||||
|
||||
/** Reads the archive back through a DataView — the format is the contract. */
|
||||
function parse(buf: Uint8Array) {
|
||||
const view = new DataView(buf.buffer);
|
||||
const dec = new TextDecoder();
|
||||
|
||||
// End-of-central-directory sits at the very end for archives without comments.
|
||||
const endAt = buf.length - 22;
|
||||
expect(view.getUint32(endAt, true)).toBe(0x06054b50);
|
||||
const count = view.getUint16(endAt + 8, true);
|
||||
const centralAt = view.getUint32(endAt + 16, true);
|
||||
|
||||
const entries: { name: string; content: string; crc: number; size: number }[] = [];
|
||||
let p = centralAt;
|
||||
for (let i = 0; i < count; i++) {
|
||||
expect(view.getUint32(p, true)).toBe(0x02014b50);
|
||||
const crc = view.getUint32(p + 16, true);
|
||||
const size = view.getUint32(p + 24, true);
|
||||
const nameLen = view.getUint16(p + 28, true);
|
||||
const localAt = view.getUint32(p + 42, true);
|
||||
const name = dec.decode(buf.slice(p + 46, p + 46 + nameLen));
|
||||
|
||||
expect(view.getUint32(localAt, true)).toBe(0x04034b50);
|
||||
const localNameLen = view.getUint16(localAt + 26, true);
|
||||
const dataAt = localAt + 30 + localNameLen + view.getUint16(localAt + 28, true);
|
||||
const content = dec.decode(buf.slice(dataAt, dataAt + size));
|
||||
|
||||
entries.push({ name, content, crc, size });
|
||||
p += 46 + nameLen + view.getUint16(p + 30, true) + view.getUint16(p + 32, true);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
describe("crc32", () => {
|
||||
/* Against the values every reference implementation agrees on — if this drifts,
|
||||
* archives still open but every entry reports as corrupt. */
|
||||
it("matches the known checksum of a plain string", () => {
|
||||
expect(crc32(bytes("hello world"))).toBe(0x0d4a1185);
|
||||
});
|
||||
|
||||
it("is zero for no bytes", () => {
|
||||
expect(crc32(new Uint8Array())).toBe(0);
|
||||
});
|
||||
|
||||
it("changes when a single byte changes", () => {
|
||||
expect(crc32(bytes("hello worlD"))).not.toBe(crc32(bytes("hello world")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("dosDateTime", () => {
|
||||
it("packs a date into the MS-DOS words", () => {
|
||||
const { date, time } = dosDateTime(new Date(2026, 7, 8, 13, 30, 20));
|
||||
expect((date >> 9) + 1980).toBe(2026);
|
||||
expect((date >> 5) & 0xf).toBe(8);
|
||||
expect(date & 0x1f).toBe(8);
|
||||
expect(time >> 11).toBe(13);
|
||||
expect((time >> 5) & 0x3f).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createZip", () => {
|
||||
it("round-trips a single text file", () => {
|
||||
const zip = createZipBytes([{ name: "notiz.md", data: "# Hallo" }]);
|
||||
const [entry] = parse(zip);
|
||||
expect(entry!.name).toBe("notiz.md");
|
||||
expect(entry!.content).toBe("# Hallo");
|
||||
});
|
||||
|
||||
it("keeps several entries with their paths", () => {
|
||||
const zip = createZipBytes([
|
||||
{ name: "Buch/eins.md", data: "eins" },
|
||||
{ name: "Buch/zwei.md", data: "zwei" },
|
||||
{ name: "Buch/images/a.png", data: new Uint8Array([1, 2, 3]) },
|
||||
]);
|
||||
const entries = parse(zip);
|
||||
expect(entries.map((e) => e.name)).toEqual(["Buch/eins.md", "Buch/zwei.md", "Buch/images/a.png"]);
|
||||
expect(entries[1]!.content).toBe("zwei");
|
||||
});
|
||||
|
||||
it("records a checksum matching the data, so archivers do not report corruption", () => {
|
||||
const zip = createZipBytes([{ name: "a.txt", data: "some content" }]);
|
||||
const [entry] = parse(zip);
|
||||
expect(entry!.crc).toBe(crc32(bytes("some content")));
|
||||
expect(entry!.size).toBe(12);
|
||||
});
|
||||
|
||||
it("stores binary data unchanged", async () => {
|
||||
const raw = new Uint8Array([0, 255, 128, 10, 13, 0]);
|
||||
const zip = createZipBytes([{ name: "b.bin", data: raw }]);
|
||||
// Data begins right after the local header and the filename.
|
||||
const start = 30 + "b.bin".length;
|
||||
expect(Array.from(zip.slice(start, start + raw.length))).toEqual(Array.from(raw));
|
||||
});
|
||||
|
||||
it("writes umlauts in filenames as UTF-8", () => {
|
||||
const zip = createZipBytes([{ name: "Übersicht.md", data: "x" }]);
|
||||
const [entry] = parse(zip);
|
||||
expect(entry!.name).toBe("Übersicht.md");
|
||||
});
|
||||
|
||||
it("produces a valid empty archive", () => {
|
||||
const zip = createZipBytes([]);
|
||||
expect(parse(zip)).toEqual([]);
|
||||
expect(zip.length).toBe(22);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* A minimal ZIP writer — stored (uncompressed) entries only.
|
||||
*
|
||||
* Exporting a notebook means handing over many files at once, and the browser
|
||||
* can only offer one download. That needs a container, and ZIP is the one every
|
||||
* operating system opens without being asked twice.
|
||||
*
|
||||
* Written out rather than pulled in: the format's stored variant is a handful of
|
||||
* fixed-layout records, which is less code than the dependency would be, and one
|
||||
* fewer thing to keep updated in a project maintained by one person. Compression
|
||||
* is skipped on purpose — note text is small, and images are already compressed.
|
||||
*/
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[i] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
function crc32(bytes: Uint8Array): number {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]!)! & 0xff]! ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
/** ZIP stores timestamps as MS-DOS date and time words. */
|
||||
function dosDateTime(d: Date): { date: number; time: number } {
|
||||
return {
|
||||
date: (((d.getFullYear() - 1980) & 0x7f) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
|
||||
time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ZipEntry {
|
||||
/** Path inside the archive; forward slashes make folders. */
|
||||
name: string;
|
||||
data: Uint8Array | string;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const toBytes = (d: Uint8Array | string) => (typeof d === "string" ? encoder.encode(d) : d);
|
||||
|
||||
/**
|
||||
* The archive as bytes.
|
||||
*
|
||||
* Kept separate from `createZip` so the format can be verified without a Blob —
|
||||
* jsdom's has no `arrayBuffer`, and a pure byte function is the easier thing to
|
||||
* reason about in any case.
|
||||
*/
|
||||
export function createZipBytes(entries: ZipEntry[], now = new Date()): Uint8Array {
|
||||
const { date, time } = dosDateTime(now);
|
||||
const chunks: Uint8Array[] = [];
|
||||
const central: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const name = encoder.encode(entry.name);
|
||||
const data = toBytes(entry.data);
|
||||
const crc = crc32(data);
|
||||
|
||||
const local = new DataView(new ArrayBuffer(30));
|
||||
local.setUint32(0, 0x04034b50, true); // local file header
|
||||
local.setUint16(4, 20, true); // version needed
|
||||
local.setUint16(6, 0x0800, true); // UTF-8 names
|
||||
local.setUint16(8, 0, true); // stored, no compression
|
||||
local.setUint16(10, time, true);
|
||||
local.setUint16(12, date, true);
|
||||
local.setUint32(14, crc, true);
|
||||
local.setUint32(18, data.length, true);
|
||||
local.setUint32(22, data.length, true);
|
||||
local.setUint16(26, name.length, true);
|
||||
local.setUint16(28, 0, true); // extra field length
|
||||
|
||||
chunks.push(new Uint8Array(local.buffer), name, data);
|
||||
|
||||
const dir = new DataView(new ArrayBuffer(46));
|
||||
dir.setUint32(0, 0x02014b50, true); // central directory header
|
||||
dir.setUint16(4, 20, true); // version made by
|
||||
dir.setUint16(6, 20, true); // version needed
|
||||
dir.setUint16(8, 0x0800, true);
|
||||
dir.setUint16(10, 0, true);
|
||||
dir.setUint16(12, time, true);
|
||||
dir.setUint16(14, date, true);
|
||||
dir.setUint32(16, crc, true);
|
||||
dir.setUint32(20, data.length, true);
|
||||
dir.setUint32(24, data.length, true);
|
||||
dir.setUint16(28, name.length, true);
|
||||
dir.setUint32(42, offset, true); // where the local header sits
|
||||
central.push(new Uint8Array(dir.buffer), name);
|
||||
|
||||
offset += 30 + name.length + data.length;
|
||||
}
|
||||
|
||||
const centralSize = central.reduce((n, c) => n + c.length, 0);
|
||||
const end = new DataView(new ArrayBuffer(22));
|
||||
end.setUint32(0, 0x06054b50, true); // end of central directory
|
||||
end.setUint16(8, entries.length, true);
|
||||
end.setUint16(10, entries.length, true);
|
||||
end.setUint32(12, centralSize, true);
|
||||
end.setUint32(16, offset, true);
|
||||
|
||||
const parts = [...chunks, ...central, new Uint8Array(end.buffer)];
|
||||
const total = parts.reduce((n, p) => n + p.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let at = 0;
|
||||
for (const part of parts) {
|
||||
out.set(part, at);
|
||||
at += part.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps bytes in a Blob.
|
||||
*
|
||||
* Goes through the underlying buffer rather than handing the view straight to
|
||||
* `new Blob`, which TypeScript rejects because a view may sit on a shared
|
||||
* buffer. The offsets are honoured, so a view over part of a larger buffer
|
||||
* yields only its own bytes.
|
||||
*/
|
||||
export function bytesToBlob(bytes: Uint8Array, type: string): Blob {
|
||||
// Copied into a plain ArrayBuffer: a view can sit on a SharedArrayBuffer,
|
||||
// which Blob does not accept, and the copy also honours the view's offsets.
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return new Blob([copy.buffer], { type });
|
||||
}
|
||||
|
||||
export function createZip(entries: ZipEntry[], now = new Date()): Blob {
|
||||
return bytesToBlob(createZipBytes(entries, now), "application/zip");
|
||||
}
|
||||
|
||||
/** Hands a blob to the browser as a download. */
|
||||
export function download(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
// Freed on the next tick; revoking immediately cancels the download in Safari.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||||
}
|
||||
|
||||
export const __testing = { crc32, dosDateTime };
|
||||
@@ -149,6 +149,7 @@ export function EditorPane({
|
||||
onRestore,
|
||||
onPermanentDelete,
|
||||
onOpenHistory,
|
||||
onExport,
|
||||
onCreateNote,
|
||||
}) {
|
||||
if (!note)
|
||||
@@ -195,6 +196,9 @@ export function EditorPane({
|
||||
minWidth: 0,
|
||||
}}
|
||||
/>
|
||||
<button onClick={onExport} title="Export" style={toolbarButton(T, T.faint)}>
|
||||
{I.export}
|
||||
</button>
|
||||
<button onClick={onOpenHistory} title="Version history" style={toolbarButton(T, T.faint)}>
|
||||
{I.history}
|
||||
</button>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -18,6 +18,7 @@ A cross-platform note-taking application with rich text editing, freehand drawin
|
||||
- **PDF Import & Annotation** — Import PDFs, overlay drawing/highlighting, persistent annotations
|
||||
- **Notebooks & Folders** — Create notebooks with custom gradient covers, organize notes by folder
|
||||
- **Star/Favorite** — Quick access to important notes
|
||||
- **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser
|
||||
- **Dark & Light Mode** — Full theme support with refined color schemes
|
||||
|
||||
### Sync & Storage
|
||||
@@ -57,6 +58,7 @@ inknote/
|
||||
│ │ ├── offlineStore.ts # IndexedDB wrapper
|
||||
│ │ ├── search.ts # Local full-text index
|
||||
│ │ ├── hooks/ # Note data, search, layout, history, drag
|
||||
│ │ ├── export/ # Markdown, ZIP and print-to-PDF
|
||||
│ │ ├── ui/ # Sidebar, dialogs, icons, palette
|
||||
│ │ ├── features/notes/ # List, editor pane, version history
|
||||
│ │ ├── features/notebooks/ # Dashboard
|
||||
@@ -65,6 +67,7 @@ inknote/
|
||||
│ ├── public/sw.js # PWA service worker
|
||||
│ ├── nginx.conf # Static serving + API/WS proxy
|
||||
│ └── dockerfile # Web container
|
||||
├── e2e/ # Browser suites — run with ./e2e/run.sh
|
||||
└── readme.md # This file
|
||||
```
|
||||
|
||||
@@ -329,6 +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 |
|
||||
| `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 |
|
||||
| `export` | 12 | the downloaded bytes are real Markdown and a real ZIP, unpacked and read back |
|
||||
|
||||
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
|
||||
@@ -475,6 +479,40 @@ rows behind.
|
||||
|
||||
---
|
||||
|
||||
## Export
|
||||
|
||||
Notes should not be hostage to this program, so everything can leave it — from a
|
||||
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*.
|
||||
|
||||
**Markdown** keeps the notes editable elsewhere. Headings, lists, quotes, code,
|
||||
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
|
||||
outside the export degrades to its plain title rather than a dead reference.
|
||||
A note with images or a drawing arrives as a ZIP so the files travel with it,
|
||||
and a whole notebook gets a `README.md` index grouped by notebook.
|
||||
|
||||
**PDF** goes through the browser's own print dialog rather than a bundled PDF
|
||||
library — every browser already has a good one behind *Save as PDF*, and it
|
||||
handles fonts and page breaks better than anything that would fit in here. This
|
||||
is the format that keeps drawings inside the document.
|
||||
|
||||
Two things Markdown cannot represent, and what happens instead:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| drawings | rendered to PNG beside the note, with a line in the text saying so |
|
||||
| underline | written as `<u>`, which every Markdown renderer passes through |
|
||||
|
||||
The ZIP writer is [`export/zip.ts`](frontend/src/export/zip.ts) — about eighty
|
||||
lines for stored (uncompressed) entries, rather than a dependency. Note text is
|
||||
small and images are already compressed, so there was nothing to gain by
|
||||
compressing, and this is one fewer thing to keep updated.
|
||||
|
||||
Everything runs in the browser. Nothing is uploaded anywhere to produce a file.
|
||||
|
||||
---
|
||||
|
||||
## Drawings
|
||||
|
||||
Drawings are **vector strokes**, not bitmaps. Every stroke is recorded in one canonical
|
||||
|
||||
Reference in new issue
Block a user