feat: add Markdown export and local demo seed

This commit is contained in:
yoma committed 2026-07-14 22:34:56 +08:00
1 parent b09af602a1
commit eaaf9cee1b
15 files changed
+689 -3

No files matched your search

+6
View File
@@ -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
+6
View File
@@ -111,6 +111,12 @@ bun install
bun run db:migrate:local
```
如需启动完全本地的开发环境,请运行下面的命令。它会先应用本地迁移,并在首次启动时使用仓库内固定的 Demo 种子初始化本地 D1/R2;后续重启会保留本地修改。
```sh
bun run dev:local
```
启动本地开发:
```sh
+87 -1
View File
@@ -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<MemoDetailRow>(),
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<ResourceRow>();
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<void> | 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;
+2
View File
@@ -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 = ({
/>
<PasswordCard authRequired={authRequired} />
<AdvancedPlayCard />
<DataExportCard />
<EvernoteImportGuideCard onShowGuide={onShowGuide} />
<McpConfigCard />
<SystemInfoCard />
@@ -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<ExportState>("idle");
const [progress, setProgress] = useState<MarkdownExportProgress>({ 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 (
<Card className="w-full min-w-0 overflow-hidden shadow-none">
<CardHeader className="p-4 pb-3">
<CardTitle className="flex items-center gap-2 text-sm">
<Download className="h-4 w-4 text-emerald-700" />
{t("dataExport.title")}
</CardTitle>
<CardDescription className="text-xs leading-4">{t("dataExport.description")}</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 p-4 pt-0">
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs leading-5 text-slate-600">
{t("dataExport.markdownDetails")}
</div>
{state === "exporting" ? (
<div className="grid gap-1.5" aria-live="polite">
<div className="flex items-center justify-between text-xs text-slate-500">
<span>{t("dataExport.exporting")}</span>
<span>{t("dataExport.progress", { completed: progress.completed, total: progress.total })}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-slate-200">
<div
className="h-full rounded-full bg-emerald-600 transition-[width]"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
) : null}
{state === "complete" ? (
<p className="flex items-center gap-1.5 text-xs text-emerald-700" aria-live="polite">
<CheckCircle2 className="h-3.5 w-3.5" />
{t("dataExport.complete")}
</p>
) : null}
{state === "error" ? (
<p className="flex items-center gap-1.5 text-xs text-red-600" role="alert">
<AlertCircle className="h-3.5 w-3.5" />
{t("dataExport.error")}
</p>
) : null}
<div className="flex justify-end">
<Button
size="sm"
type="button"
className="w-full sm:w-auto"
disabled={state === "exporting"}
onClick={() => void handleExport()}
>
<Download className="h-4 w-4" />
{state === "exporting" ? t("dataExport.exportingButton") : t("dataExport.exportButton")}
</Button>
</div>
</CardContent>
</Card>
);
};
+11
View File
@@ -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",
+11
View File
@@ -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: "管理",
+24
View File
@@ -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<ListResourcesResponse>("/api/v1/resources"),
getMarkdownExportPage: (offset = 0, limit = 50) =>
request<MarkdownExportPage>(`/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);
+233
View File
@@ -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<MarkdownExportPage>;
getResourceBlob: (resourceUrl: string) => Promise<Blob>;
};
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<string>) => {
if (!usedNames.has(candidate.toLocaleLowerCase())) {
usedNames.add(candidate.toLocaleLowerCase());
return candidate;
}
let suffix = 2;
while (usedNames.has(`${candidate} (${suffix})`.toLocaleLowerCase())) {
suffix += 1;
}
const unique = `${candidate} (${suffix})`;
usedNames.add(unique.toLocaleLowerCase());
return unique;
};
export const buildNotebookExportPaths = (notebooks: Notebook[]) => {
const notebookById = new Map(notebooks.map((notebook) => [notebook.id, notebook]));
const siblingNames = new Map<string, Set<string>>();
const segmentById = new Map<string, string>();
for (const notebook of notebooks) {
const parentKey = notebook.parentId ?? "__root__";
const usedNames = siblingNames.get(parentKey) ?? new Set<string>();
siblingNames.set(parentKey, usedNames);
segmentById.set(
notebook.id,
uniqueName(sanitizeExportPathSegment(notebook.name, "Untitled notebook"), usedNames)
);
}
const pathById = new Map<string, string>();
const resolvePath = (notebookId: string, visited = new Set<string>()): string => {
const cached = pathById.get(notebookId);
if (cached) {
return cached;
}
const notebook = notebookById.get(notebookId);
if (!notebook || visited.has(notebookId)) {
return "Unfiled";
}
visited.add(notebookId);
const segment = segmentById.get(notebookId) ?? "Untitled notebook";
const path = notebook.parentId && notebookById.has(notebook.parentId)
? `${resolvePath(notebook.parentId, visited)}/${segment}`
: segment;
pathById.set(notebookId, path);
return path;
};
for (const notebook of notebooks) {
resolvePath(notebook.id);
}
return pathById;
};
const yamlString = (value: string) => JSON.stringify(value);
export const buildMarkdownFrontMatter = (memo: MemoDetail, notebookPath: string) => [
"---",
`title: ${yamlString(memo.title?.trim() || "Untitled note")}`,
`tags: ${JSON.stringify(memo.tags)}`,
`notebook: ${yamlString(notebookPath)}`,
`created: ${yamlString(memo.createdAt)}`,
`updated: ${yamlString(memo.updatedAt)}`,
`pinned: ${memo.isPinned}`,
`edgeever_id: ${yamlString(memo.id)}`,
"---",
"",
"",
].join("\n");
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<string, Set<string>>();
const chunks: ArrayBuffer[] = [];
const result = new Promise<Blob>((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<string, Resource[]>();
for (const resource of page.resources) {
const resources = resourcesByMemo.get(resource.memoId) ?? [];
resources.push(resource);
resourcesByMemo.set(resource.memoId, resources);
}
for (const memo of page.memos) {
const notebookPath = notebookPaths.get(memo.notebookId) ?? "Unfiled";
const usedMemoNames = memoNamesByNotebook.get(notebookPath) ?? new Set<string>();
memoNamesByNotebook.set(notebookPath, usedMemoNames);
const memoStem = uniqueName(
sanitizeExportPathSegment(memo.title?.trim() || "Untitled note", "Untitled note"),
usedMemoNames
);
const assetDirectory = `${memoStem}.assets`;
const usedResourceNames = new Set<string>();
let markdown = memo.contentMarkdown;
for (const resource of resourcesByMemo.get(memo.id) ?? []) {
const fallbackName = `${resource.kind}-${resource.id}.${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);
};
+3
View File
@@ -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=="],
+60
View File
@@ -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",
+3 -2
View File
@@ -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",
+33
View File
@@ -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<ListResourcesResponse>("/api/v1/resources"),
getMarkdownExportPage: (offset = 0, limit = 50) =>
request<MarkdownExportPage>(`/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<ResourceResponse>(`/api/v1/memos/${memoId}/resources`, {
method: "POST",
+6
View File
@@ -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;
+107
View File
@@ -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<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> = {}): 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)"
);
});
});