From a4a10a32b9055d89ab5bdd6c020b8c4a62d0762a Mon Sep 17 00:00:00 2001 From: tianma-if Date: Thu, 10 Sep 2026 17:07:46 +0800 Subject: [PATCH] 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. --- apps/api/src/backup-routes.test.mjs | 109 +++++++++++-- apps/api/src/backup-routes.ts | 79 +++++++++- .../src/components/MemoListMobileSheets.tsx | 26 ++- apps/web/src/components/MemoListPane.tsx | 34 ++++ apps/web/src/components/WorkspaceApp.tsx | 81 ++++++++++ apps/web/src/lib/markdown-export.ts | 27 +++- apps/web/src/lib/selected-markdown-export.ts | 70 ++++++++ packages/client/src/index.ts | 12 +- packages/shared/src/i18n/en-US.ts | 11 ++ packages/shared/src/i18n/zh-CN.ts | 11 ++ tests/selected-markdown-export-ui.test.ts | 31 ++++ tests/selected-markdown-export.test.ts | 149 ++++++++++++++++++ 12 files changed, 616 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/lib/selected-markdown-export.ts create mode 100644 tests/selected-markdown-export-ui.test.ts create mode 100644 tests/selected-markdown-export.test.ts diff --git a/apps/api/src/backup-routes.test.mjs b/apps/api/src/backup-routes.test.mjs index d7507e19..9eefd991 100644 --- a/apps/api/src/backup-routes.test.mjs +++ b/apps/api/src/backup-routes.test.mjs @@ -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 () => { diff --git a/apps/api/src/backup-routes.ts b/apps/api/src/backup-routes.ts index c4e7ca4d..5ac5ea05 100644 --- a/apps/api/src/backup-routes.ts +++ b/apps/api/src/backup-routes.ts @@ -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(), + 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, 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[] = []; diff --git a/apps/web/src/components/MemoListMobileSheets.tsx b/apps/web/src/components/MemoListMobileSheets.tsx index 8be8dfbd..4e105683 100644 --- a/apps/web/src/components/MemoListMobileSheets.tsx +++ b/apps/web/src/components/MemoListMobileSheets.tsx @@ -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 = ({ {pinLabel} + + + + + + + {exportTitle} + +