diff --git a/README.md b/README.md index b7547855..9ecb31e5 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,12 @@ Apply local D1 migrations: bun run db:migrate:local ``` +For a fully local development environment, run the command below. It applies pending local migrations and initializes the local D1/R2 stores once with the repository's fixed demo seed. Existing local changes are preserved on later restarts. + +```sh +bun run dev:local +``` + Start local development: ```sh diff --git a/README.zh-CN.md b/README.zh-CN.md index fbfd0f1f..1fa9871f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -111,6 +111,12 @@ bun install bun run db:migrate:local ``` +如需启动完全本地的开发环境,请运行下面的命令。它会先应用本地迁移,并在首次启动时使用仓库内固定的 Demo 种子初始化本地 D1/R2;后续重启会保留本地修改。 + +```sh +bun run dev:local +``` + 启动本地开发: ```sh diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3b5b8208..c6e5977a 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -49,6 +49,7 @@ type Bindings = { EDGE_EVER_SESSION_TTL_DAYS?: string; EDGE_EVER_R2_BUCKET_NAME?: string; EDGE_EVER_DEMO_MODE?: string; + EDGE_EVER_LOCAL_DEMO_SEED?: string; }; type AuthContext = { @@ -1246,6 +1247,60 @@ app.post("/api/v1/memos/:id/revisions/:revisionId/restore", async (c) => { return c.json({ memo: await getMemoDetail(c.env.DB, memoId) }); }); +app.get("/api/v1/exports/markdown", async (c) => { + const denied = requireScopes(c, "read:memos", "read:resources"); + + if (denied) { + return denied; + } + + const limit = clampNumber(Number(c.req.query("limit") ?? 50), 1, 100); + const offset = clampNumber(Number(c.req.query("offset") ?? 0), 0, 1_000_000); + const [memoRows, totalRow] = await Promise.all([ + c.env.DB.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 + WHERE m.is_deleted = 0 + ORDER BY m.created_at ASC, m.id ASC + LIMIT ? OFFSET ?` + ) + .bind(limit, offset) + .all(), + c.env.DB.prepare(`SELECT COUNT(*) AS count FROM memos WHERE is_deleted = 0`).first<{ count: number }>(), + ]); + + const memoIds = memoRows.results.map((row) => row.id); + let resources: Resource[] = []; + + if (memoIds.length > 0) { + const placeholders = memoIds.map(() => "?").join(", "); + const resourceRows = await c.env.DB.prepare( + `SELECT id, memo_id, original_memo_id, bucket_name, object_key, kind, mime_type, + filename, byte_size, sha256, width, height, created_at, updated_at + FROM resources + WHERE is_deleted = 0 AND memo_id IN (${placeholders}) + ORDER BY memo_id ASC, created_at ASC, id ASC` + ) + .bind(...memoIds) + .all(); + resources = resourceRows.results.map(mapResource); + } + + const totalCount = totalRow?.count ?? memoRows.results.length; + const nextOffset = offset + memoRows.results.length < totalCount ? offset + memoRows.results.length : null; + + return c.json({ + memos: memoRows.results.map(mapMemoDetail), + resources, + totalCount, + nextOffset, + }); +}); + app.get("/api/v1/resources", async (c) => { const denied = requireScopes(c, "read:resources"); @@ -1951,7 +2006,11 @@ app.post("/mcp", async (c) => { }); const worker = { - fetch(request: Request, env: Bindings, ctx: ExecutionContext) { + async fetch(request: Request, env: Bindings, ctx: ExecutionContext) { + if (isLocalDemoSeedEnabled(env)) { + await ensureLocalDemoSeed(env); + } + return app.fetch(request, env, ctx); }, async scheduled(controller: ScheduledController, env: Bindings, ctx: ExecutionContext) { @@ -4216,6 +4275,33 @@ const emptyTrashMemosRecord = async ( }; const isDemoMode = (env: Bindings) => env.EDGE_EVER_DEMO_MODE?.trim().toLowerCase() === "true"; +const isLocalDemoSeedEnabled = (env: Bindings) => + env.EDGE_EVER_LOCAL_DEMO_SEED?.trim().toLowerCase() === "true"; + +let localDemoSeedPromise: Promise | null = null; + +const ensureLocalDemoSeed = (env: Bindings) => { + localDemoSeedPromise ??= (async () => { + const existingMarker = await env.DB.prepare( + `SELECT id FROM audit_events WHERE action = 'demo.local_seed' LIMIT 1` + ).first<{ id: string }>(); + + if (existingMarker) { + return; + } + + await ensureDemoSeed(env); + await audit(env.DB, "system", null, "demo.local_seed", "demo", "edgeever-local", { + seedMemoCount: DEMO_SEED_MEMOS.length, + mode: "non-destructive", + }); + })().catch((error) => { + localDemoSeedPromise = null; + throw error; + }); + + return localDemoSeedPromise; +}; const ensureDemoSeed = async (env: Bindings, options: { refreshResources?: boolean } = {}) => { const db = env.DB; diff --git a/apps/web/src/components/SettingsPane.tsx b/apps/web/src/components/SettingsPane.tsx index f7e9647f..6f6bfc84 100644 --- a/apps/web/src/components/SettingsPane.tsx +++ b/apps/web/src/components/SettingsPane.tsx @@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button"; import type { ShortcutSettings } from "@/lib/app-helpers"; import { WORKSPACE_PAGE_TITLE_CLASSNAME } from "@/lib/workspace-ui"; import { AdvancedPlayCard } from "./settings/AdvancedPlayCard"; +import { DataExportCard } from "./settings/DataExportCard"; import { EvernoteImportGuideCard } from "./settings/EvernoteImportGuideCard"; import { McpConfigCard } from "./settings/McpConfigCard"; import { PreferenceCard } from "./settings/PreferenceCard"; @@ -71,6 +72,7 @@ export const SettingsPane = ({ /> + diff --git a/apps/web/src/components/settings/DataExportCard.tsx b/apps/web/src/components/settings/DataExportCard.tsx new file mode 100644 index 00000000..05b4ea12 --- /dev/null +++ b/apps/web/src/components/settings/DataExportCard.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import { AlertCircle, CheckCircle2, Download } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { api } from "@/lib/api"; +import { createMarkdownExport, downloadMarkdownExport, type MarkdownExportProgress } from "@/lib/markdown-export"; + +type ExportState = "idle" | "exporting" | "complete" | "error"; + +export const DataExportCard = () => { + const { t } = useTranslation(); + const [state, setState] = useState("idle"); + const [progress, setProgress] = useState({ completed: 0, total: 0 }); + + const handleExport = async () => { + setState("exporting"); + setProgress({ completed: 0, total: 0 }); + + try { + const blob = await createMarkdownExport( + { + listNotebooks: api.listNotebooks, + getPage: api.getMarkdownExportPage, + getResourceBlob: api.getResourceBlob, + }, + setProgress + ); + downloadMarkdownExport(blob); + setState("complete"); + } catch (error) { + console.error("Failed to export Markdown ZIP", error); + setState("error"); + } + }; + + const percentage = progress.total > 0 ? Math.round((progress.completed / progress.total) * 100) : 0; + + return ( + + + + + {t("dataExport.title")} + + {t("dataExport.description")} + + +
+ {t("dataExport.markdownDetails")} +
+ + {state === "exporting" ? ( +
+
+ {t("dataExport.exporting")} + {t("dataExport.progress", { completed: progress.completed, total: progress.total })} +
+
+
+
+
+ ) : null} + + {state === "complete" ? ( +

+ + {t("dataExport.complete")} +

+ ) : null} + + {state === "error" ? ( +

+ + {t("dataExport.error")} +

+ ) : null} + +
+ +
+ + + ); +}; diff --git a/apps/web/src/i18n/resources/en-US.ts b/apps/web/src/i18n/resources/en-US.ts index 041bc703..93957b99 100644 --- a/apps/web/src/i18n/resources/en-US.ts +++ b/apps/web/src/i18n/resources/en-US.ts @@ -163,6 +163,17 @@ export const enUS = { unknown: "Unknown", copy: "Copy info", }, + dataExport: { + title: "Export notes", + description: "Export all active notes in this workspace as a Markdown ZIP archive.", + markdownDetails: "Each note is saved as a separate Markdown file with tags, notebook, and timestamps in Front Matter. Images and attachments are stored in a matching .assets folder and referenced with relative paths. Notes in Trash are excluded.", + exportButton: "Export Markdown ZIP", + exportingButton: "Exporting", + exporting: "Preparing notes and attachments…", + progress: "{{completed}} / {{total}} notes", + complete: "Export complete. Your ZIP download has started.", + error: "Export failed. Check your connection and try again.", + }, shortcuts: { title: "Keyboard shortcuts", manage: "Manage", diff --git a/apps/web/src/i18n/resources/zh-CN.ts b/apps/web/src/i18n/resources/zh-CN.ts index 222d7b6e..91f2f3e9 100644 --- a/apps/web/src/i18n/resources/zh-CN.ts +++ b/apps/web/src/i18n/resources/zh-CN.ts @@ -163,6 +163,17 @@ export const zhCN = { unknown: "未知", copy: "复制信息", }, + dataExport: { + title: "导出笔记", + description: "将当前工作区中的全部有效笔记导出为 Markdown ZIP。", + markdownDetails: "每篇笔记会保存为独立的 Markdown 文件,标签、笔记本和时间信息写入 Front Matter;图片与附件保存在对应的 .assets 文件夹中,并使用相对路径引用。回收站中的笔记不会导出。", + exportButton: "导出 Markdown ZIP", + exportingButton: "正在导出", + exporting: "正在整理笔记和附件…", + progress: "{{completed}} / {{total}} 篇", + complete: "导出完成,ZIP 文件已开始下载。", + error: "导出失败,请检查网络连接后重试。", + }, shortcuts: { title: "绑定快捷键", manage: "管理", diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index e5f0e831..20dc90f6 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -55,6 +55,13 @@ type ResourceResponse = { resource: Resource; }; +export type MarkdownExportPage = { + memos: MemoDetail[]; + resources: Resource[]; + totalCount: number; + nextOffset: number | null; +}; + export class ApiRequestError extends Error { status: number; code?: string; @@ -257,6 +264,23 @@ export const api = { listResources: () => request("/api/v1/resources"), + getMarkdownExportPage: (offset = 0, limit = 50) => + request(`/api/v1/exports/markdown?offset=${offset}&limit=${limit}`), + + getResourceBlob: async (resourceUrl: string) => { + const response = await fetch(resourceUrl, { credentials: "include" }); + + if (!response.ok) { + if (response.status === 401) { + window.dispatchEvent(new CustomEvent("edgeever:unauthorized")); + } + + throw new ApiRequestError(response.statusText || "Resource download failed", response.status); + } + + return response.blob(); + }, + uploadMemoResource: (memoId: string, file: File) => { const form = new FormData(); form.append("file", file); diff --git a/apps/web/src/lib/markdown-export.ts b/apps/web/src/lib/markdown-export.ts new file mode 100644 index 00000000..19f9fe08 --- /dev/null +++ b/apps/web/src/lib/markdown-export.ts @@ -0,0 +1,233 @@ +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; +}; + +type MarkdownExportSource = { + listNotebooks: () => Promise<{ notebooks: Notebook[] }>; + getPage: (offset: number, limit: number) => Promise; + getResourceBlob: (resourceUrl: string) => Promise; +}; + +const EXPORT_PAGE_SIZE = 50; +const ZIP_MIME_TYPE = "application/zip"; +const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; + +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; +}; + +const uniqueName = (candidate: string, usedNames: Set) => { + 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>(); + const segmentById = new Map(); + + for (const notebook of notebooks) { + const parentKey = notebook.parentId ?? "__root__"; + const usedNames = siblingNames.get(parentKey) ?? new Set(); + siblingNames.set(parentKey, usedNames); + segmentById.set( + notebook.id, + uniqueName(sanitizeExportPathSegment(notebook.name, "Untitled notebook"), usedNames) + ); + } + + const pathById = new Map(); + const resolvePath = (notebookId: string, visited = new Set()): 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"); + +const getExtension = (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("/"); + +const replaceResourceUrl = (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>(); + const chunks: ArrayBuffer[] = []; + const result = new Promise((resolve, reject) => { + const zip = new Zip((error, data, final) => { + if (error) { + reject(error); + 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(); + + 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(); + memoNamesByNotebook.set(notebookPath, usedMemoNames); + const memoStem = uniqueName( + sanitizeExportPathSegment(memo.title?.trim() || "Untitled note", "Untitled note"), + usedMemoNames + ); + const assetDirectory = `${memoStem}.assets`; + const usedResourceNames = new Set(); + let markdown = memo.contentMarkdown; + + for (const resource of resourcesByMemo.get(memo.id) ?? []) { + const fallbackName = `${resource.kind}-${resource.id}.${getExtension(resource)}`; + const resourceName = uniqueName( + 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 = replaceResourceUrl(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); +}; diff --git a/bun.lock b/bun.lock index 5aa01df2..96559a9c 100644 --- a/bun.lock +++ b/bun.lock @@ -27,6 +27,7 @@ "cmdk": "^1.1.1", "dexie": "latest", "fast-xml-parser": "^5.9.3", + "fflate": "^0.8.3", "hono": "latest", "i18next": "^26.3.4", "lucide-react": "latest", @@ -1631,6 +1632,8 @@ "fetch-nodeshim": ["fetch-nodeshim@0.4.10", "", {}, "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="], "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], diff --git a/docs/openapi.json b/docs/openapi.json index 5908fa2d..714b7bb7 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -830,6 +830,66 @@ } } }, + "/api/v1/exports/markdown": { + "get": { + "summary": "Read a page of Markdown export data", + "description": "Returns active memo details and their resource metadata for browser-side Markdown ZIP generation. Requires read:memos and read:resources scopes.", + "parameters": [ + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "A page of active memos and related resources", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "memos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemoDetail" + } + }, + "resources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Resource" + } + }, + "totalCount": { + "type": "integer" + }, + "nextOffset": { + "type": ["integer", "null"] + } + }, + "required": ["memos", "resources", "totalCount", "nextOffset"] + } + } + } + } + } + } + }, "/api/v1/resources": { "get": { "summary": "List resources and storage summary", diff --git a/package.json b/package.json index 18b2ce46..279dd264 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "scripts": { "dev": "concurrently -k -n api,web -c cyan,magenta \"bun run dev:api\" \"bun run dev:web\"", "dev:api": "bun scripts/run-wrangler.mjs dev --remote --port 8787", - "dev:local": "concurrently -k -n api,web -c cyan,magenta \"bun run dev:api:local\" \"bun run dev:web\"", - "dev:api:local": "bun scripts/run-wrangler.mjs dev --local --port 8787", + "dev:local": "bun run db:migrate:local && concurrently -k -n api,web -c cyan,magenta \"bun run dev:api:local\" \"bun run dev:web\"", + "dev:api:local": "EDGE_EVER_LOCAL_DEMO_SEED=true bun scripts/run-wrangler.mjs dev --local --port 8787", "dev:web": "vite --config apps/web/vite.config.ts --host 127.0.0.1 --port 5173", "dev:mobile": "bun --cwd apps/mobile expo start", "dev:site": "bun --cwd apps/site dev", @@ -73,6 +73,7 @@ "cmdk": "^1.1.1", "dexie": "latest", "fast-xml-parser": "^5.9.3", + "fflate": "^0.8.3", "hono": "latest", "i18next": "^26.3.4", "lucide-react": "latest", diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 4fab87fb..f02a723c 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -64,6 +64,13 @@ export type ResourceResponse = { resource: Resource; }; +export type MarkdownExportPage = { + memos: MemoDetail[]; + resources: Resource[]; + totalCount: number; + nextOffset: number | null; +}; + export class ApiRequestError extends Error { status: number; code?: string; @@ -274,6 +281,32 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => { listResources: () => request("/api/v1/resources"), + getMarkdownExportPage: (offset = 0, limit = 50) => + request(`/api/v1/exports/markdown?offset=${offset}&limit=${limit}`), + + getResourceBlob: async (resourceUrl: string) => { + const headers = new Headers(); + + if (options.token) { + headers.set("Authorization", `Bearer ${options.token}`); + } + + const response = await fetchImpl(`${baseUrl}${resourceUrl}`, { + credentials: "include", + headers, + }); + + if (!response.ok) { + if (response.status === 401) { + options.onUnauthorized?.(); + } + + throw new ApiRequestError(response.statusText || "Resource download failed", response.status); + } + + return response.blob(); + }, + uploadMemoResource: (memoId: string, file: FormData) => request(`/api/v1/memos/${memoId}/resources`, { method: "POST", diff --git a/scripts/run-wrangler.mjs b/scripts/run-wrangler.mjs index 71f0a3c9..3eadddc9 100644 --- a/scripts/run-wrangler.mjs +++ b/scripts/run-wrangler.mjs @@ -128,6 +128,7 @@ const runtimeVars = { EDGE_EVER_SESSION_TTL_DAYS: envValue("SESSION_TTL_DAYS"), EDGE_EVER_R2_BUCKET_NAME: envValue("R2_BUCKET_NAME"), EDGE_EVER_DEMO_MODE: envValue("DEMO_MODE"), + EDGE_EVER_LOCAL_DEMO_SEED: envValue("LOCAL_DEMO_SEED"), }; const runtimeVarLines = Object.entries(runtimeVars) .filter(([, value]) => Boolean(value)) @@ -147,6 +148,11 @@ if (demoMode && !["true", "false"].includes(demoMode)) { throw new Error("EDGE_EVER_DEMO_MODE must be true or false."); } +const localDemoSeed = envValue("LOCAL_DEMO_SEED")?.toLowerCase(); +if (localDemoSeed && !["true", "false"].includes(localDemoSeed)) { + throw new Error("EDGE_EVER_LOCAL_DEMO_SEED must be true or false."); +} + if (demoMode === "true") { const demoResetCron = envValue("DEMO_RESET_CRON") || "0 19 * * *"; changed = true; diff --git a/tests/markdown-export.test.ts b/tests/markdown-export.test.ts new file mode 100644 index 00000000..a8544f0e --- /dev/null +++ b/tests/markdown-export.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; +import type { MemoDetail, Notebook, Resource } from "@edgeever/shared"; +import { strFromU8, unzipSync } from "fflate"; +import { + buildMarkdownFrontMatter, + buildNotebookExportPaths, + createMarkdownExport, + sanitizeExportPathSegment, +} from "../apps/web/src/lib/markdown-export"; + +const notebook = (input: Partial & Pick): 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 => ({ + id: "memo_1", + notebookId: "nb_child", + title: "项目笔记", + excerpt: "", + tags: ["工作", "重要"], + isPinned: true, + isArchived: false, + isDeleted: false, + revision: 1, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + deletedAt: null, + contentJson: { type: "doc", content: [] }, + contentMarkdown: "![diagram](/api/v1/resources/res_1/blob)", + contentText: "", + contentHash: "hash", + sourceMemoIds: [], + mergeSourceCount: 0, + mergedIntoMemoId: null, + ...input, +}); + +const resource: Resource = { + id: "res_1", + memoId: "memo_1", + originalMemoId: null, + kind: "image", + mimeType: "image/png", + filename: "设计 图.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("Markdown export", () => { + test("sanitizes unsafe and reserved path names", () => { + expect(sanitizeExportPathSegment("CON", "fallback")).toBe("_CON"); + expect(sanitizeExportPathSegment('<>:"/\\|?*', "fallback")).toBe("---------"); + expect(sanitizeExportPathSegment("...", "fallback")).toBe("fallback"); + }); + + test("preserves notebook hierarchy and disambiguates sibling names", () => { + const paths = buildNotebookExportPaths([ + notebook({ id: "nb_root", name: "工作" }), + notebook({ id: "nb_child", name: "项目", parentId: "nb_root" }), + notebook({ id: "nb_child_2", name: "项目", parentId: "nb_root" }), + ]); + + expect(paths.get("nb_child")).toBe("工作/项目"); + expect(paths.get("nb_child_2")).toBe("工作/项目 (2)"); + }); + + test("writes front matter, Markdown, and relative assets into the ZIP", async () => { + const notes = [memo()]; + const blob = await createMarkdownExport({ + listNotebooks: async () => ({ + notebooks: [ + notebook({ id: "nb_root", name: "工作" }), + notebook({ id: "nb_child", name: "项目", parentId: "nb_root" }), + ], + }), + getPage: async () => ({ memos: notes, resources: [resource], totalCount: 1, nextOffset: null }), + getResourceBlob: async () => new Blob([new Uint8Array([1, 2, 3])]), + }); + const files = unzipSync(new Uint8Array(await blob.arrayBuffer())); + const markdownPath = "工作/项目/项目笔记.md"; + const assetPath = "工作/项目/项目笔记.assets/设计 图.png"; + + expect(Object.keys(files).sort()).toEqual([assetPath, markdownPath].sort()); + expect(Array.from(files[assetPath])).toEqual([1, 2, 3]); + + const markdown = strFromU8(files[markdownPath]); + expect(markdown).toStartWith(buildMarkdownFrontMatter(notes[0], "工作/项目")); + expect(markdown).toContain( + "![diagram](%E9%A1%B9%E7%9B%AE%E7%AC%94%E8%AE%B0.assets/%E8%AE%BE%E8%AE%A1%20%E5%9B%BE.png)" + ); + }); +});