Files
edgeever/apps/web/src/lib/markdown-export.ts
T
tianma-if a4a10a32b9 feat(web): export selected notes as a Markdown ZIP
Add a bulk Markdown ZIP action for selected notes, reuse the existing
readable export layout, and keep the desktop bulk action card visible
when a diagram note is open.
2026-09-10 17:07:46 +08:00

257 lines
8.4 KiB
TypeScript

import type { MemoDetail, Notebook, Resource } from "@edgeever/shared";
import { strToU8, Zip, ZipDeflate, ZipPassThrough } from "fflate";
export type MarkdownExportProgress = {
completed: number;
total: number;
};
export type MarkdownExportPage = {
memos: MemoDetail[];
resources: Resource[];
totalCount: number;
nextOffset: number | null;
};
export type MarkdownExportSource = {
listNotebooks: () => Promise<{ notebooks: Notebook[] }>;
getPage: (offset: number, limit: number) => Promise<MarkdownExportPage>;
getResourceBlob: (resourceUrl: string) => Promise<Blob>;
};
const EXPORT_PAGE_SIZE = 50;
const ZIP_MIME_TYPE = "application/zip";
const MAX_BUFFERED_ZIP_BYTES = 128 * 1024 * 1024;
const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
export class MarkdownExportMemoryLimitError extends Error {
constructor() {
super("This Markdown export is too large for an in-memory ZIP.");
this.name = "MarkdownExportMemoryLimitError";
}
}
export const sanitizeExportPathSegment = (value: string, fallback: string) => {
const sanitized = value
.replace(/[\u0000-\u001f<>:"/\\|?*]/g, "-")
.replace(/\s+/g, " ")
.replace(/[. ]+$/g, "")
.trim()
.slice(0, 100);
const safeValue = sanitized || fallback;
return WINDOWS_RESERVED_NAME.test(safeValue) ? `_${safeValue}` : safeValue;
};
export const uniqueExportName = (candidate: string, usedNames: Set<string>) => {
if (!usedNames.has(candidate.toLocaleLowerCase())) {
usedNames.add(candidate.toLocaleLowerCase());
return candidate;
}
let suffix = 2;
while (usedNames.has(`${candidate} (${suffix})`.toLocaleLowerCase())) {
suffix += 1;
}
const unique = `${candidate} (${suffix})`;
usedNames.add(unique.toLocaleLowerCase());
return unique;
};
export const buildNotebookExportPaths = (notebooks: Notebook[]) => {
const notebookById = new Map(notebooks.map((notebook) => [notebook.id, notebook]));
const siblingNames = new Map<string, Set<string>>();
const segmentById = new Map<string, string>();
for (const notebook of notebooks) {
const parentKey = notebook.parentId ?? "__root__";
const usedNames = siblingNames.get(parentKey) ?? new Set<string>();
siblingNames.set(parentKey, usedNames);
segmentById.set(
notebook.id,
uniqueExportName(sanitizeExportPathSegment(notebook.name, "Untitled notebook"), usedNames)
);
}
const pathById = new Map<string, string>();
const resolvePath = (notebookId: string, visited = new Set<string>()): string => {
const cached = pathById.get(notebookId);
if (cached) {
return cached;
}
const notebook = notebookById.get(notebookId);
if (!notebook || visited.has(notebookId)) {
return "Unfiled";
}
visited.add(notebookId);
const segment = segmentById.get(notebookId) ?? "Untitled notebook";
const path = notebook.parentId && notebookById.has(notebook.parentId)
? `${resolvePath(notebook.parentId, visited)}/${segment}`
: segment;
pathById.set(notebookId, path);
return path;
};
for (const notebook of notebooks) {
resolvePath(notebook.id);
}
return pathById;
};
const yamlString = (value: string) => JSON.stringify(value);
export const buildMarkdownFrontMatter = (memo: MemoDetail, notebookPath: string) => [
"---",
`title: ${yamlString(memo.title?.trim() || "Untitled note")}`,
`tags: ${JSON.stringify(memo.tags)}`,
`notebook: ${yamlString(notebookPath)}`,
`created: ${yamlString(memo.createdAt)}`,
`updated: ${yamlString(memo.updatedAt)}`,
`pinned: ${memo.isPinned}`,
`edgeever_id: ${yamlString(memo.id)}`,
"---",
"",
"",
].join("\n");
export const getExportResourceExtension = (resource: Resource) => {
const filenameExtension = resource.filename?.match(/\.([a-z0-9]{1,12})$/i)?.[1];
if (filenameExtension) {
return filenameExtension.toLowerCase();
}
const mimeExtension = resource.mimeType?.split("/")[1]?.split(/[;+]/)[0];
return mimeExtension ? sanitizeExportPathSegment(mimeExtension, "bin").toLowerCase() : "bin";
};
const encodeRelativePath = (path: string) => path.split("/").map(encodeURIComponent).join("/");
export const replaceExportResourceUrl = (markdown: string, resourceUrl: string, relativePath: string) => {
const encodedPath = encodeRelativePath(relativePath);
const absoluteUrl = typeof window === "undefined" ? null : new URL(resourceUrl, window.location.origin).toString();
let result = markdown.split(resourceUrl).join(encodedPath);
if (absoluteUrl) {
result = result.split(absoluteUrl).join(encodedPath);
}
return result;
};
export const createMarkdownExport = async (
source: MarkdownExportSource,
onProgress?: (progress: MarkdownExportProgress) => void
) => {
const { notebooks } = await source.listNotebooks();
const notebookPaths = buildNotebookExportPaths(notebooks);
const memoNamesByNotebook = new Map<string, Set<string>>();
const chunks: ArrayBuffer[] = [];
const result = new Promise<Blob>((resolve, reject) => {
let totalBytes = 0;
let failed = false;
let zip!: Zip;
zip = new Zip((error, data, final) => {
if (failed) {
return;
}
if (error) {
failed = true;
reject(error);
return;
}
totalBytes += data.byteLength;
if (totalBytes > MAX_BUFFERED_ZIP_BYTES) {
failed = true;
zip.terminate();
reject(new MarkdownExportMemoryLimitError());
return;
}
chunks.push(new Uint8Array(data).buffer);
if (final) {
resolve(new Blob(chunks, { type: ZIP_MIME_TYPE }));
}
});
void (async () => {
try {
let offset = 0;
let completed = 0;
while (true) {
const page = await source.getPage(offset, EXPORT_PAGE_SIZE);
onProgress?.({ completed, total: page.totalCount });
const resourcesByMemo = new Map<string, Resource[]>();
for (const resource of page.resources) {
const resources = resourcesByMemo.get(resource.memoId) ?? [];
resources.push(resource);
resourcesByMemo.set(resource.memoId, resources);
}
for (const memo of page.memos) {
const notebookPath = notebookPaths.get(memo.notebookId) ?? "Unfiled";
const usedMemoNames = memoNamesByNotebook.get(notebookPath) ?? new Set<string>();
memoNamesByNotebook.set(notebookPath, usedMemoNames);
const memoStem = uniqueExportName(
sanitizeExportPathSegment(memo.title?.trim() || "Untitled note", "Untitled note"),
usedMemoNames
);
const assetDirectory = `${memoStem}.assets`;
const usedResourceNames = new Set<string>();
let markdown = memo.contentMarkdown;
for (const resource of resourcesByMemo.get(memo.id) ?? []) {
const fallbackName = `${resource.kind}-${resource.id}.${getExportResourceExtension(resource)}`;
const resourceName = uniqueExportName(
sanitizeExportPathSegment(resource.filename || fallbackName, fallbackName),
usedResourceNames
);
const relativePath = `${assetDirectory}/${resourceName}`;
const blob = await source.getResourceBlob(resource.url);
const file = new ZipPassThrough(`${notebookPath}/${relativePath}`);
zip.add(file);
file.push(new Uint8Array(await blob.arrayBuffer()), true);
markdown = replaceExportResourceUrl(markdown, resource.url, relativePath);
}
const markdownFile = new ZipDeflate(`${notebookPath}/${memoStem}.md`, { level: 6 });
zip.add(markdownFile);
markdownFile.push(strToU8(`${buildMarkdownFrontMatter(memo, notebookPath)}${markdown}`), true);
completed += 1;
onProgress?.({ completed, total: page.totalCount });
}
if (page.nextOffset === null) {
break;
}
offset = page.nextOffset;
}
zip.end();
} catch (error) {
zip.terminate();
reject(error);
}
})();
});
return result;
};
export const downloadMarkdownExport = (blob: Blob) => {
const date = new Date().toISOString().slice(0, 10);
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `edgeever-markdown-${date}.zip`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1_000);
};