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:
1 parent
04439b5a1a
commit
a4a10a32b9
12 files changed
+616
-24
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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: "更多",
|
||||
},
|
||||
|
||||
@@ -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}");
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user