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.
This commit is contained in:
tianma-if committed 2026-09-10 17:07:46 +08:00
1 parent 04439b5a1a
commit a4a10a32b9
12 files changed
+616 -24

No files matched your search

+97 -12
View File
@@ -1,6 +1,11 @@
import { describe, expect, test } from "bun:test";
import { Hono } from "hono";
import { mapJsonBackupRevision, registerBackupRoutes } from "./backup-routes.ts";
import {
MAX_MARKDOWN_EXPORT_MEMO_IDS,
mapJsonBackupRevision,
parseMarkdownExportMemoIds,
registerBackupRoutes,
} from "./backup-routes.ts";
const userAuth = {
kind: "user",
@@ -64,18 +69,25 @@ const resourceRow = {
updated_at: "2026-08-08T00:00:00.000Z",
};
const createDatabase = () => ({
prepare: (sql) => ({
bind: () => ({
all: async () => {
if (sql.includes("FROM memos m")) return { results: [memoRow] };
if (sql.includes("FROM resources")) return { results: [resourceRow] };
return { results: [] };
const createDatabase = () => {
const statements = [];
return {
statements,
prepare: (sql) => ({
bind: (...args) => {
statements.push({ sql, args });
return {
all: async () => {
if (sql.includes("FROM memos m")) return { results: [memoRow] };
if (sql.includes("FROM resources")) return { results: [resourceRow] };
return { results: [] };
},
first: async () => sql.includes("COUNT(*)") ? { count: 2 } : null,
};
},
first: async () => sql.includes("COUNT(*)") ? { count: 2 } : null,
}),
}),
});
};
};
const createDependencies = (overrides = {}) => ({
clampNumber: (value, min, max) => Math.min(Math.max(value, min), max),
@@ -114,10 +126,11 @@ describe("backup route contracts", () => {
});
test("returns a paginated export with mapped resources", async () => {
const database = createDatabase();
const response = await createApp(createDependencies()).request(
"/api/v1/exports/markdown?limit=1&offset=0",
{},
{ storage: { db: createDatabase(), resources: {} } },
{ storage: { db: database, resources: {} } },
);
expect(response.status).toBe(200);
@@ -127,6 +140,78 @@ describe("backup route contracts", () => {
totalCount: 2,
nextOffset: 1,
});
expect(database.statements.some((statement) => statement.sql.includes("json_each"))).toBe(false);
});
test("filters markdown export pages to requested memo ids in the current workspace", async () => {
const database = createDatabase();
const response = await createApp(createDependencies()).request(
"/api/v1/exports/markdown?ids=memo_1,memo_missing&limit=50&offset=0",
{},
{ storage: { db: database, resources: {} } },
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
memos: [{ id: "memo_1", title: "Backup memo" }],
resources: [{ id: "res_1", memoId: "memo_1", filename: "note.txt" }],
});
const memoQuery = database.statements.find((statement) => statement.sql.includes("FROM memos m"));
expect(memoQuery.sql).toContain("json_each(?)");
expect(memoQuery.sql).toContain("m.workspace_id = ?");
expect(memoQuery.sql).toContain("m.is_deleted = 0");
expect(memoQuery.args[0]).toBe("ws_1");
expect(JSON.parse(memoQuery.args[1])).toEqual(["memo_1", "memo_missing"]);
});
test("rejects markdown export requests that exceed the batch size", async () => {
const ids = Array.from({ length: MAX_MARKDOWN_EXPORT_MEMO_IDS + 1 }, (_, index) => `memo_${index}`).join(",");
const response = await createApp(createDependencies()).request(
`/api/v1/exports/markdown?ids=${ids}`,
{},
{
storage: {
db: { prepare: () => { throw new Error("Unexpected database access"); } },
resources: {},
},
},
);
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({
error: { code: "bad_request" },
});
});
test("returns an empty markdown export page when ids is present but empty", async () => {
const response = await createApp(createDependencies()).request(
"/api/v1/exports/markdown?ids=",
{},
{
storage: {
db: { prepare: () => { throw new Error("Unexpected database access"); } },
resources: {},
},
},
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
memos: [],
resources: [],
totalCount: 0,
nextOffset: null,
});
});
test("parses markdown export memo ids without interpolating them into SQL", () => {
expect(parseMarkdownExportMemoIds(undefined)).toEqual({ ids: null });
expect(parseMarkdownExportMemoIds("")).toEqual({ ids: [] });
expect(parseMarkdownExportMemoIds(" memo_1, memo_2, memo_1 ")).toEqual({ ids: ["memo_1", "memo_2"] });
expect(parseMarkdownExportMemoIds(Array.from({ length: 101 }, (_, index) => `memo_${index}`).join(","))).toEqual({
ids: null,
error: "too_many",
});
});
test("allows only user sessions to restore notebooks", async () => {
+72 -7
View File
@@ -112,6 +112,28 @@ export const mapJsonBackupRevision = (row: BackupRevisionRow): JsonBackupRevisio
createdAt: row.created_at,
});
export const MAX_MARKDOWN_EXPORT_MEMO_IDS = 100;
export const parseMarkdownExportMemoIds = (value: string | undefined): { ids: string[] | null; error?: "too_many" } => {
if (value === undefined) {
return { ids: null };
}
const ids = [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
if (ids.length > MAX_MARKDOWN_EXPORT_MEMO_IDS) {
return { ids: null, error: "too_many" };
}
return { ids };
};
const MEMO_EXPORT_SELECT_SQL = `SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned,
m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, mc.revision,
mc.content_json, mc.content_markdown, mc.content_text, mc.content_hash,
m.source_memo_ids, m.merge_source_count, m.merged_into_memo_id
FROM memos m
INNER JOIN memo_contents mc ON mc.memo_id = m.id`;
const listBackupPage = async (
database: DatabaseAdapter,
workspaceId: string,
@@ -119,12 +141,7 @@ const listBackupPage = async (
offset: number,
) => Promise.all([
database.prepare(
`SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned,
m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, mc.revision,
mc.content_json, mc.content_markdown, mc.content_text, mc.content_hash,
m.source_memo_ids, m.merge_source_count, m.merged_into_memo_id
FROM memos m
INNER JOIN memo_contents mc ON mc.memo_id = m.id
`${MEMO_EXPORT_SELECT_SQL}
WHERE m.workspace_id = ? AND m.is_deleted = 0
ORDER BY m.created_at ASC, m.id ASC
LIMIT ? OFFSET ?`,
@@ -134,6 +151,44 @@ const listBackupPage = async (
).bind(workspaceId).first<{ count: number }>(),
]);
const emptyMarkdownExportPage = async () => [
{ results: [] as BackupMemoDetailRow[] },
{ count: 0 },
] as const;
const listMarkdownExportPage = async (
database: DatabaseAdapter,
workspaceId: string,
limit: number,
offset: number,
memoIds: string[] | null,
) => {
if (memoIds && memoIds.length === 0) {
return emptyMarkdownExportPage();
}
if (!memoIds) {
return listBackupPage(database, workspaceId, limit, offset);
}
// json_each keeps the D1 bound-parameter count fixed (workspace + JSON + limit/offset).
const memoIdsJson = JSON.stringify(memoIds);
return Promise.all([
database.prepare(
`${MEMO_EXPORT_SELECT_SQL}
WHERE m.workspace_id = ? AND m.is_deleted = 0
AND m.id IN (SELECT value FROM json_each(?))
ORDER BY m.created_at ASC, m.id ASC
LIMIT ? OFFSET ?`,
).bind(workspaceId, memoIdsJson, limit, offset).all<BackupMemoDetailRow>(),
database.prepare(
`SELECT COUNT(*) AS count FROM memos
WHERE workspace_id = ? AND is_deleted = 0
AND id IN (SELECT value FROM json_each(?))`,
).bind(workspaceId, memoIdsJson).first<{ count: number }>(),
]);
};
export const registerBackupRoutes = (
app: Hono<AppEnv>,
dependencies: BackupRouteDependencies,
@@ -144,8 +199,18 @@ export const registerBackupRoutes = (
const limit = dependencies.clampNumber(Number(context.req.query("limit") ?? 50), 1, 100);
const offset = dependencies.clampNumber(Number(context.req.query("offset") ?? 0), 0, 1_000_000);
const parsedIds = parseMarkdownExportMemoIds(context.req.query("ids"));
if (parsedIds.error === "too_many") {
return badRequest(context, `Export at most ${MAX_MARKDOWN_EXPORT_MEMO_IDS} notes at a time.`);
}
const workspaceId = getWorkspaceId(context);
const [memoRows, totalRow] = await listBackupPage(context.env.storage.db, workspaceId, limit, offset);
const [memoRows, totalRow] = await listMarkdownExportPage(
context.env.storage.db,
workspaceId,
limit,
offset,
parsedIds.ids,
);
const memoIds = memoRows.results.map((row) => row.id);
let resources: Resource[] = [];
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, type ReactNode } from "react";
import { Archive, CheckSquare, Folder as NotebookIcon, KeyRound, LayoutList, List, Merge, Star, Tags, Trash2, X } from "lucide-react";
import { Archive, CheckSquare, FileDown, Folder as NotebookIcon, KeyRound, LayoutList, List, Merge, Star, Tags, Trash2, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
@@ -18,6 +18,7 @@ import {
DrawerTitle,
} from "@/components/ui/drawer";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { getMemoSortOptions, getNotebookMoveOptions } from "@/lib/app-helpers";
import type { MemoListDensity, MemoSortMode } from "@/lib/app-helpers";
@@ -265,9 +266,11 @@ export const MobileMoveSheet = ({
};
export const MobileSelectionMoreSheet = ({
canExport,
canMerge,
canPin,
canToggleVisibleSelection,
exportTitle,
mergeTitle,
pinLabel,
pinTitle,
@@ -276,13 +279,16 @@ export const MobileSelectionMoreSheet = ({
selectionToggleTitle,
onClearSelection,
onClose,
onExport,
onMerge,
onPin,
onToggleVisibleSelection,
}: {
canExport: boolean;
canMerge: boolean;
canPin: boolean;
canToggleVisibleSelection: boolean;
exportTitle: string;
mergeTitle: string;
pinLabel: string;
pinTitle: string;
@@ -291,6 +297,7 @@ export const MobileSelectionMoreSheet = ({
selectionToggleTitle: string;
onClearSelection: () => void;
onClose: () => void;
onExport: () => void;
onMerge: () => void;
onPin: () => void;
onToggleVisibleSelection: () => void;
@@ -340,6 +347,23 @@ export const MobileSelectionMoreSheet = ({
<Star className="h-4 w-4" />
{pinLabel}
</button>
<Tooltip>
<TooltipTrigger asChild>
<span className="block w-full border-b border-slate-100">
<button
className="flex h-12 w-full items-center gap-3 px-4 text-left text-sm font-medium text-slate-800 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:text-slate-300 disabled:opacity-100 disabled:hover:bg-transparent"
type="button"
disabled={!canExport}
aria-label={exportTitle}
onClick={onExport}
>
<FileDown className="h-4 w-4" />
{t("mobileSheets.exportMarkdown")}
</button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">{exportTitle}</TooltipContent>
</Tooltip>
<button
className="flex h-12 w-full items-center gap-3 px-4 text-left text-sm font-medium text-slate-800 transition hover:bg-slate-50"
type="button"
+34
View File
@@ -105,7 +105,9 @@ const getSelectionCountLabel = (count: number, t: ReturnType<typeof useTranslati
export const MemoSelectionActionBar = ({
deleteTitle,
exportTitle,
isDeleting,
isExporting,
isMerging,
isMoving,
isPinning,
@@ -116,6 +118,7 @@ export const MemoSelectionActionBar = ({
moveTitle,
onClearSelection,
onDelete,
onExport,
onMerge,
onMove,
onPin,
@@ -126,7 +129,9 @@ export const MemoSelectionActionBar = ({
onMoveTargetChange,
}: {
deleteTitle: string;
exportTitle: string;
isDeleting: boolean;
isExporting: boolean;
isMerging: boolean;
isMoving: boolean;
isPinning: boolean;
@@ -137,6 +142,7 @@ export const MemoSelectionActionBar = ({
moveTitle: string;
onClearSelection: () => void;
onDelete: () => void;
onExport: () => void;
onMerge: () => void;
onMove: () => void;
onPin: () => void;
@@ -207,6 +213,16 @@ export const MemoSelectionActionBar = ({
<Merge className="h-4 w-4" />
{t("memoList.mergeMemos")}
</Button>
<Button
className="h-11 w-full justify-start rounded-none px-3 text-slate-700 hover:bg-slate-50"
variant="ghost"
title={exportTitle}
onClick={onExport}
disabled={selectedCount === 0 || isExporting || isTrashView}
>
<FileDown className="h-4 w-4" />
{t("workspace.selection.export")}
</Button>
<div className="h-px bg-slate-100" />
<Button
className="h-11 w-full justify-start rounded-none px-3 text-rose-700 hover:bg-rose-50 hover:text-rose-700"
@@ -349,6 +365,7 @@ export const MemoListPane = ({
isPinning,
isMoving,
isMerging,
isExporting,
isDeleting,
view,
search,
@@ -367,6 +384,7 @@ export const MemoListPane = ({
onRequestDocumentAction,
onMoveSelectedMemos,
onPinSelectedMemos,
onExportSelectedMemos,
onDeleteSelectedMemos,
onEmptyTrash,
onMerge,
@@ -425,6 +443,7 @@ export const MemoListPane = ({
isPinning: boolean;
isMoving: boolean;
isMerging: boolean;
isExporting: boolean;
isDeleting: boolean;
view: string;
search: string;
@@ -443,6 +462,7 @@ export const MemoListPane = ({
onRequestDocumentAction: (memoId: string, action: MemoDocumentAction, printWindow?: Window | null) => void;
onMoveSelectedMemos: (notebookId: string) => void;
onPinSelectedMemos: (pinned: boolean) => void;
onExportSelectedMemos: () => void;
onDeleteSelectedMemos: () => void;
onEmptyTrash: () => void;
onMerge: () => void;
@@ -529,6 +549,14 @@ export const MemoListPane = ({
selectedMemoIds.size === 0 ? t("workspace.selection.chooseMemo") : isDeleting ? t("workspace.selection.deleting") : view === "trash" ? t("workspace.selection.permanentDelete") : t("workspace.selection.delete");
const selectionMergeTitle =
selectedMemoIds.size < 2 ? t("workspace.selection.needTwoMemos") : view === "trash" ? t("workspace.selection.trashCannotMerge") : isMerging ? t("workspace.selection.merging") : t("workspace.selection.merge");
const selectionExportTitle =
selectedMemoIds.size === 0
? t("workspace.selection.chooseMemo")
: view === "trash"
? t("workspace.selection.trashCannotExport")
: isExporting
? t("workspace.selection.exporting")
: t("workspace.selection.exportHint");
const allSelectedMemosPinned = selectedMemosInList.length > 0 && selectedMemosInList.every((memo) => memo.isPinned);
const selectedPinTarget = !allSelectedMemosPinned;
const selectionPinLabel = allSelectedMemosPinned ? t("workspace.selection.unpin") : t("workspace.selection.pin");
@@ -1745,9 +1773,11 @@ export const MemoListPane = ({
{mobileMoreOpen && (
<Suspense fallback={null}>
<MobileSelectionMoreSheet
canExport={selectedMemoIds.size > 0 && view !== "trash" && !isExporting}
canMerge={selectedMemoIds.size >= 2 && view !== "trash" && !isMerging}
canPin={selectedMemoIds.size > 0 && view !== "trash" && !isPinning}
canToggleVisibleSelection={canToggleVisibleMemoSelection}
exportTitle={selectionExportTitle}
mergeTitle={selectionMergeTitle}
pinLabel={selectionPinLabel}
pinTitle={selectionPinTitle}
@@ -1771,6 +1801,10 @@ export const MemoListPane = ({
setMobileMoreOpen(false);
onMerge();
}}
onExport={() => {
setMobileMoreOpen(false);
onExportSelectedMemos();
}}
onPin={() => {
setMobileMoreOpen(false);
onPinSelectedMemos(selectedPinTarget);
+81
View File
@@ -32,6 +32,8 @@ import { QuickMemoSwitcher } from "./QuickMemoSwitcher";
import { AppConfirmDialog, MemoDeleteConfirmDialog, NotebookNameDialog } from "./dialogs/ConfirmDialogs";
import { PluginPanelDialog } from "./plugins/PluginPanelDialog";
import { api, getOrCreateClientDeviceId } from "@/lib/api";
import { MarkdownExportMemoryLimitError, type MarkdownExportProgress } from "@/lib/markdown-export";
import { exportSelectedMemosAsMarkdownZip } from "@/lib/selected-markdown-export";
import { createPluginScheduleAdapter } from "@/lib/plugins/plugin-schedule-adapter";
import {
clearMobileEditorReturnPreview,
@@ -752,6 +754,11 @@ export const WorkspaceApp = ({
const [notebookNameDialog, setNotebookNameDialog] = useState<NotebookNameDialogState | null>(null);
const [notebookDeleteConfirmation, setNotebookDeleteConfirmation] = useState<Notebook | null>(null);
const [appNoticeDialog, setAppNoticeDialog] = useState<AppNoticeDialogState | null>(null);
const [isExportingSelectedMemos, setIsExportingSelectedMemos] = useState(false);
const [selectedMarkdownExportProgress, setSelectedMarkdownExportProgress] = useState<MarkdownExportProgress>({
completed: 0,
total: 0,
});
const [demoResetConfirmationOpen, setDemoResetConfirmationOpen] = useState(false);
const scheduledTaskDeviceId = useMemo(
() => window.edgeeverDesktop?.isAvailable ? getOrCreateClientDeviceId() : null,
@@ -2339,6 +2346,60 @@ export const WorkspaceApp = ({
});
};
const handleExportSelectedMemos = () => {
if (selectedMemoIds.size === 0 || memoView === "trash" || isExportingSelectedMemos) {
return;
}
setIsExportingSelectedMemos(true);
setSelectedMarkdownExportProgress({ completed: 0, total: selectedMemoIds.size });
void exportSelectedMemosAsMarkdownZip({
memoIds: Array.from(selectedMemoIds),
listNotebooks: api.listNotebooks,
getPage: api.getMarkdownExportPage,
getResourceBlob: api.getResourceBlob,
onProgress: setSelectedMarkdownExportProgress,
}).then((result) => {
if (result.status === "too-many") {
setAppNoticeDialog({
title: t("workspace.selection.export"),
description: t("workspace.selection.exportTooMany", { max: result.max }),
});
return;
}
if (result.status === "local-only") {
setAppNoticeDialog({
title: t("workspace.selection.export"),
description: t("workspace.selection.exportLocalOnly"),
});
return;
}
if (result.status === "empty") {
setAppNoticeDialog({
title: t("workspace.selection.export"),
description: t("workspace.selection.exportEmpty"),
});
return;
}
if (result.skippedLocal > 0) {
setAppNoticeDialog({
title: t("workspace.selection.export"),
description: t("workspace.selection.exportCompleteWithSkipped", { count: result.skippedLocal }),
});
}
}).catch((error: unknown) => {
console.error("Failed to export selected notes as Markdown ZIP", error);
setAppNoticeDialog({
title: t("workspace.selection.export"),
description: error instanceof MarkdownExportMemoryLimitError
? t("dataExport.largeBackupRequiresStreaming")
: t("workspace.selection.exportError"),
});
}).finally(() => {
setIsExportingSelectedMemos(false);
});
};
const handleDeleteSelectedMemos = () => {
if (selectedMemoIds.size === 0) {
return;
@@ -2389,6 +2450,17 @@ export const WorkspaceApp = ({
: mergeMutation.isPending
? t("workspace.selection.merging")
: t("workspace.selection.merge");
const selectionExportTitle =
selectedMemoIds.size === 0
? t("workspace.selection.chooseMemo")
: memoView === "trash"
? t("workspace.selection.trashCannotExport")
: isExportingSelectedMemos
? t("workspace.selection.exportProgress", {
completed: selectedMarkdownExportProgress.completed,
total: selectedMarkdownExportProgress.total,
})
: t("workspace.selection.exportHint");
const selectionDeleteTitle =
selectedMemoIds.size === 0
? t("workspace.selection.chooseMemo")
@@ -2400,7 +2472,9 @@ export const WorkspaceApp = ({
const memoSelectionActionBar = memoSelectionModeActive ? (
<MemoSelectionActionBar
deleteTitle={selectionDeleteTitle}
exportTitle={selectionExportTitle}
isDeleting={deleteMemosMutation.isPending || deleteMemoMutation.isPending}
isExporting={isExportingSelectedMemos}
isMerging={mergeMutation.isPending}
isMoving={moveMemosMutation.isPending}
isPinning={pinMemosMutation.isPending}
@@ -2411,6 +2485,7 @@ export const WorkspaceApp = ({
moveTitle={selectionMoveTitle}
onClearSelection={clearMemoSelection}
onDelete={handleDeleteSelectedMemos}
onExport={handleExportSelectedMemos}
onMerge={handleMerge}
onMove={() => handleMoveSelectedMemos(selectionMoveTargetNotebookId)}
onMoveTargetChange={setSelectionMoveTargetNotebookId}
@@ -3311,6 +3386,7 @@ export const WorkspaceApp = ({
isError={memosQuery.isError}
isCreating={createMemoMutation.isPending}
isMerging={mergeMutation.isPending}
isExporting={isExportingSelectedMemos}
isMoving={moveMemosMutation.isPending}
isPinning={pinMemosMutation.isPending}
isDeleting={deleteMemosMutation.isPending || deleteMemoMutation.isPending}
@@ -3391,6 +3467,7 @@ export const WorkspaceApp = ({
}}
onTogglePinMemo={handleToggleMemoPinned}
onPinSelectedMemos={handlePinSelectedMemos}
onExportSelectedMemos={handleExportSelectedMemos}
onDeleteSelectedMemos={handleDeleteSelectedMemos}
onMoveSelectedMemos={handleMoveSelectedMemos}
mobileListActionsOpen={mobileListActionsOpen}
@@ -3492,6 +3569,10 @@ export const WorkspaceApp = ({
<EvernoteImportGuidePane onClose={() => setRightView("settings")} onOpenExecutionCenter={handleOpenExecutionCenter} />
) : rendererRecoveryMode ? (
<EditorRecoveryPane />
) : memoSelectionModeActive ? (
<div className="flex h-full min-w-0 flex-col bg-white">
{memoSelectionActionBar}
</div>
) : (
<EditorPaneErrorBoundary
resetKey={selectedMemo?.id ?? selectedMemoId}
+25 -2
View File
@@ -13,7 +13,7 @@ export type MarkdownExportPage = {
nextOffset: number | null;
};
type MarkdownExportSource = {
export type MarkdownExportSource = {
listNotebooks: () => Promise<{ notebooks: Notebook[] }>;
getPage: (offset: number, limit: number) => Promise<MarkdownExportPage>;
getResourceBlob: (resourceUrl: string) => Promise<Blob>;
@@ -21,8 +21,16 @@ type MarkdownExportSource = {
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, "-")
@@ -142,12 +150,27 @@ export const createMarkdownExport = async (
const memoNamesByNotebook = new Map<string, Set<string>>();
const chunks: ArrayBuffer[] = [];
const result = new Promise<Blob>((resolve, reject) => {
const zip = new Zip((error, data, final) => {
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 }));
@@ -0,0 +1,70 @@
import type { Notebook } from "@edgeever/shared";
import { isLocalMemoId } from "./local-mirror";
import {
createMarkdownExport,
downloadMarkdownExport,
type MarkdownExportPage,
type MarkdownExportProgress,
} from "./markdown-export";
export const MAX_SELECTED_MARKDOWN_EXPORT_MEMO_IDS = 100;
export type SelectedMarkdownExportResult =
| { status: "ok"; exported: number; skippedLocal: number }
| { status: "too-many"; selected: number; max: number }
| { status: "local-only"; skippedLocal: number }
| { status: "empty" };
export const partitionExportableMemoIds = (memoIds: string[]) => {
const skippedLocal = memoIds.filter((memoId) => isLocalMemoId(memoId));
const exportable = [...new Set(memoIds.filter((memoId) => !isLocalMemoId(memoId)))];
return { exportable, skippedLocal };
};
export const exportSelectedMemosAsMarkdownZip = async ({
memoIds,
listNotebooks,
getPage,
getResourceBlob,
onProgress,
save = downloadMarkdownExport,
}: {
memoIds: string[];
listNotebooks: () => Promise<{ notebooks: Notebook[] }>;
getPage: (offset: number, limit: number, memoIds: string[]) => Promise<MarkdownExportPage>;
getResourceBlob: (resourceUrl: string) => Promise<Blob>;
onProgress?: (progress: MarkdownExportProgress) => void;
save?: (blob: Blob) => void;
}): Promise<SelectedMarkdownExportResult> => {
const { exportable, skippedLocal } = partitionExportableMemoIds(memoIds);
if (exportable.length === 0) {
return skippedLocal.length > 0
? { status: "local-only", skippedLocal: skippedLocal.length }
: { status: "empty" };
}
if (exportable.length > MAX_SELECTED_MARKDOWN_EXPORT_MEMO_IDS) {
return {
status: "too-many",
selected: exportable.length,
max: MAX_SELECTED_MARKDOWN_EXPORT_MEMO_IDS,
};
}
let exported = 0;
const blob = await createMarkdownExport({
listNotebooks,
getPage: async (offset, limit) => {
const page = await getPage(offset, limit, exportable);
exported = page.totalCount;
return page;
},
getResourceBlob,
}, onProgress);
if (exported === 0) {
return { status: "empty" };
}
save(blob);
return { status: "ok", exported, skippedLocal: skippedLocal.length };
};
+10 -2
View File
@@ -1179,8 +1179,16 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
method: "DELETE",
}),
getMarkdownExportPage: (offset = 0, limit = 50) =>
request<MarkdownExportPage>(`/api/v1/exports/markdown?offset=${offset}&limit=${limit}`),
getMarkdownExportPage: (offset = 0, limit = 50, memoIds?: string[]) => {
const search = new URLSearchParams({
offset: String(offset),
limit: String(limit),
});
if (memoIds && memoIds.length > 0) {
search.set("ids", memoIds.join(","));
}
return request<MarkdownExportPage>(`/api/v1/exports/markdown?${search.toString()}`);
},
getJsonBackupPage: (offset = 0, limit = 25) =>
request<JsonBackupPage>(`/api/v1/backups/json?offset=${offset}&limit=${limit}`),
+11
View File
@@ -430,6 +430,16 @@ export const enUS = {
trashCannotMerge: "Notes in trash cannot be merged",
merging: "Merging",
merge: "Merge notes",
export: "Export Markdown",
exportHint: "Pack the last saved version of the selected notes into a Markdown ZIP with attachments",
exporting: "Exporting",
exportProgress: "Exporting {{completed}} / {{total}}",
trashCannotExport: "Notes in trash cannot be exported",
exportTooMany: "Export at most {{max}} notes at a time",
exportLocalOnly: "Unsynced notes cannot be exported. Sync them first, then try again.",
exportEmpty: "There are no notes to export",
exportError: "Export failed. Check the network connection and try again.",
exportCompleteWithSkipped: "Started downloading the Markdown ZIP. Skipped {{count}} unsynced notes.",
deleting: "Deleting",
permanentDelete: "Delete permanently",
delete: "Delete",
@@ -1333,6 +1343,7 @@ export const enUS = {
moveTo: "Move to {{name}}",
bulkActions: "Bulk actions",
mergeMemos: "Merge notes",
exportMarkdown: "Export Markdown",
clearSelection: "Clear selection",
more: "More",
},
+11
View File
@@ -430,6 +430,16 @@ export const zhCN = {
trashCannotMerge: "回收站内不可合并",
merging: "正在合并",
merge: "合并笔记",
export: "导出 Markdown",
exportHint: "将选中笔记的已保存版本打包为 Markdown 压缩包(含附件)",
exporting: "正在导出",
exportProgress: "正在导出 {{completed}} / {{total}}",
trashCannotExport: "回收站内不可导出",
exportTooMany: "一次最多导出 {{max}} 条笔记",
exportLocalOnly: "未同步的笔记无法导出,请先同步后再试",
exportEmpty: "没有可导出的笔记",
exportError: "导出失败,请检查网络连接后重试",
exportCompleteWithSkipped: "已开始下载 Markdown 压缩包。已跳过 {{count}} 条未同步笔记。",
deleting: "正在删除",
permanentDelete: "永久删除",
delete: "删除",
@@ -1333,6 +1343,7 @@ export const zhCN = {
moveTo: "移动到 {{name}}",
bulkActions: "批量操作",
mergeMemos: "合并笔记",
exportMarkdown: "导出 Markdown",
clearSelection: "取消选择",
more: "更多",
},
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
const readSource = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
describe("selected Markdown ZIP export UI", () => {
test("exposes Markdown ZIP export in desktop and mobile bulk actions", () => {
const memoListSource = readSource("../apps/web/src/components/MemoListPane.tsx");
const mobileSheetSource = readSource("../apps/web/src/components/MemoListMobileSheets.tsx");
const workspaceSource = readSource("../apps/web/src/components/WorkspaceApp.tsx");
expect(memoListSource).toContain('t("workspace.selection.export")');
expect(memoListSource).toContain("onExportSelectedMemos()");
expect(mobileSheetSource).toContain('t("mobileSheets.exportMarkdown")');
expect(mobileSheetSource).toContain("onClick={onExport}");
expect(workspaceSource).toContain("handleExportSelectedMemos");
expect(workspaceSource).toContain("exportSelectedMemosAsMarkdownZip");
expect(mobileSheetSource).not.toContain("export-html");
expect(mobileSheetSource).not.toContain("export-pdf");
});
test("shows the desktop bulk action card even when a diagram note is open", () => {
const workspaceSource = readSource("../apps/web/src/components/WorkspaceApp.tsx");
const selectionOverride = workspaceSource.indexOf("memoSelectionModeActive ? (");
const diagramPane = workspaceSource.indexOf("<DiagramEditorPane");
expect(selectionOverride).toBeGreaterThan(0);
expect(diagramPane).toBeGreaterThan(selectionOverride);
expect(workspaceSource.slice(selectionOverride, diagramPane)).toContain("{memoSelectionActionBar}");
});
});
+149
View File
@@ -0,0 +1,149 @@
import { describe, expect, test } from "bun:test";
import type { MemoDetail, Notebook, Resource } from "@edgeever/shared";
import { unzipSync } from "fflate";
import {
exportSelectedMemosAsMarkdownZip,
MAX_SELECTED_MARKDOWN_EXPORT_MEMO_IDS,
partitionExportableMemoIds,
} from "../apps/web/src/lib/selected-markdown-export";
const notebook = (input: Partial<Notebook> & Pick<Notebook, "id" | "name">): Notebook => ({
id: input.id,
parentId: input.parentId ?? null,
name: input.name,
slug: null,
icon: null,
color: null,
sortOrder: 0,
memoCount: 0,
lastMemoUpdatedAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
const memo = (input: Partial<MemoDetail> & Pick<MemoDetail, "id" | "title">): MemoDetail => ({
id: input.id,
notebookId: "nb_root",
title: input.title,
excerpt: "",
tags: [],
isPinned: false,
isArchived: false,
isDeleted: false,
revision: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
deletedAt: null,
contentJson: { type: "doc", content: [] },
contentMarkdown: input.contentMarkdown ?? "Hello",
contentText: "Hello",
contentHash: "hash",
sourceMemoIds: [],
mergeSourceCount: 0,
mergedIntoMemoId: null,
});
const resource: Resource = {
id: "res_1",
memoId: "memo_keep",
originalMemoId: null,
kind: "image",
mimeType: "image/png",
filename: "photo.png",
byteSize: 3,
sha256: null,
width: null,
height: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
url: "/api/v1/resources/res_1/blob",
};
describe("selected Markdown ZIP export", () => {
test("drops unsynced local ids before talking to the export API", () => {
expect(partitionExportableMemoIds(["memo_1", "local_draft", "memo_1", "local_other"])).toEqual({
exportable: ["memo_1"],
skippedLocal: ["local_draft", "local_other"],
});
});
test("refuses selections larger than the batch limit", async () => {
const memoIds = Array.from({ length: MAX_SELECTED_MARKDOWN_EXPORT_MEMO_IDS + 1 }, (_, index) => `memo_${index}`);
const result = await exportSelectedMemosAsMarkdownZip({
memoIds,
listNotebooks: async () => ({ notebooks: [] }),
getPage: async () => {
throw new Error("should not fetch pages for oversized selections");
},
getResourceBlob: async () => new Blob(),
save: () => {
throw new Error("should not download an oversized selection");
},
});
expect(result).toEqual({
status: "too-many",
selected: MAX_SELECTED_MARKDOWN_EXPORT_MEMO_IDS + 1,
max: MAX_SELECTED_MARKDOWN_EXPORT_MEMO_IDS,
});
});
test("skips download when every selected note is still local", async () => {
const result = await exportSelectedMemosAsMarkdownZip({
memoIds: ["local_one", "local_two"],
listNotebooks: async () => ({ notebooks: [] }),
getPage: async () => {
throw new Error("should not fetch pages for local-only selections");
},
getResourceBlob: async () => new Blob(),
save: () => {
throw new Error("should not download a local-only selection");
},
});
expect(result).toEqual({ status: "local-only", skippedLocal: 2 });
});
test("writes only the requested notes and reports skipped local drafts", async () => {
const keep = memo({ id: "memo_keep", title: "保留笔记" });
let requestedIds: string[] | undefined;
const saved: Blob[] = [];
const result = await exportSelectedMemosAsMarkdownZip({
memoIds: ["memo_keep", "local_draft", "memo_keep"],
listNotebooks: async () => ({ notebooks: [notebook({ id: "nb_root", name: "工作" })] }),
getPage: async (_offset, _limit, memoIds) => {
requestedIds = memoIds;
return {
memos: [keep],
resources: [resource],
totalCount: 1,
nextOffset: null,
};
},
getResourceBlob: async () => new Blob([new Uint8Array([1, 2, 3])]),
save: (blob) => saved.push(blob),
});
expect(requestedIds).toEqual(["memo_keep"]);
expect(result).toEqual({ status: "ok", exported: 1, skippedLocal: 1 });
expect(saved).toHaveLength(1);
expect(Object.keys(unzipSync(new Uint8Array(await saved[0].arrayBuffer()))).sort()).toEqual([
"工作/保留笔记.assets/photo.png",
"工作/保留笔记.md",
]);
});
test("does not download an empty archive when none of the ids exist", async () => {
const result = await exportSelectedMemosAsMarkdownZip({
memoIds: ["memo_missing"],
listNotebooks: async () => ({ notebooks: [] }),
getPage: async () => ({ memos: [], resources: [], totalCount: 0, nextOffset: null }),
getResourceBlob: async () => new Blob(),
save: () => {
throw new Error("should not download an empty archive");
},
});
expect(result).toEqual({ status: "empty" });
});
});