feat: add Markdown export and local demo seed
This commit is contained in:
1 parent
b09af602a1
commit
eaaf9cee1b
15 files changed
+689
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -111,6 +111,12 @@ bun install
|
||||
bun run db:migrate:local
|
||||
```
|
||||
|
||||
如需启动完全本地的开发环境,请运行下面的命令。它会先应用本地迁移,并在首次启动时使用仓库内固定的 Demo 种子初始化本地 D1/R2;后续重启会保留本地修改。
|
||||
|
||||
```sh
|
||||
bun run dev:local
|
||||
```
|
||||
|
||||
启动本地开发:
|
||||
|
||||
```sh
|
||||
|
||||
+87
-1
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "管理",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: "",
|
||||
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(
|
||||
""
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user