From 828b518008c084b0134d367a10c586d287a20a11 Mon Sep 17 00:00:00 2001 From: owensun6 Date: Sun, 12 Apr 2026 21:23:20 +0800 Subject: [PATCH] feat(memos-local): add data export endpoint and UI (#1474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SqliteStore.exportAll(): dump all memories/tasks/skills as JSON - SqliteStore.exportMemoriesAsCsv(): memories-only CSV with ISO timestamps - GET /api/export?format=json|csv endpoint in ViewerServer - Export buttons in Settings → General tab (i18n: en + zh) - exportData() JS helper triggers file download via click Closes #1474 --- .../src/storage/sqlite.ts | 33 +++++++++++++++++ apps/memos-local-openclaw/src/viewer/html.ts | 25 +++++++++++++ .../memos-local-openclaw/src/viewer/server.ts | 37 +++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/apps/memos-local-openclaw/src/storage/sqlite.ts b/apps/memos-local-openclaw/src/storage/sqlite.ts index 09f9c2bf..31969233 100644 --- a/apps/memos-local-openclaw/src/storage/sqlite.ts +++ b/apps/memos-local-openclaw/src/storage/sqlite.ts @@ -2873,6 +2873,39 @@ export class SqliteStore { return result; } + // ─── Export ─── + + exportAll(): { memories: unknown[]; tasks: unknown[]; skills: unknown[] } { + const memories = this.db.prepare( + "SELECT * FROM chunks ORDER BY created_at ASC", + ).all(); + + const tasks = this.db.prepare( + "SELECT * FROM tasks ORDER BY started_at ASC", + ).all(); + + const skills = this.db.prepare( + "SELECT id, name, description, content, version, status, visibility, owner, created_at, updated_at FROM skills ORDER BY created_at ASC", + ).all(); + + return { memories, tasks, skills }; + } + + exportMemoriesAsCsv(): string { + const rows = this.db.prepare( + "SELECT id, session_key, role, summary, content, created_at FROM chunks ORDER BY created_at ASC", + ).all() as Array<{ id: string; session_key: string; role: string; summary: string; content: string; created_at: number }>; + + const escape = (v: string) => `"${String(v ?? "").replace(/"/g, '""')}"`; + const header = ["id", "session_key", "role", "summary", "content", "created_at"].join(","); + const lines = rows.map((r) => + [r.id, r.session_key, r.role, r.summary, r.content, new Date(r.created_at).toISOString()] + .map(escape) + .join(","), + ); + return [header, ...lines].join("\n"); + } + close(): void { this.db.close(); } diff --git a/apps/memos-local-openclaw/src/viewer/html.ts b/apps/memos-local-openclaw/src/viewer/html.ts index 5e4456e7..3a2398d5 100644 --- a/apps/memos-local-openclaw/src/viewer/html.ts +++ b/apps/memos-local-openclaw/src/viewer/html.ts @@ -1837,6 +1837,13 @@ input,textarea,select{font-family:inherit;font-size:inherit} +
+
\u{1F4E4} Export Data
+
Download all your memories, tasks, and skills as a backup file. Choose JSON for full data or CSV for memories only.
+
+ + +
@@ -2327,6 +2334,10 @@ const I18N={ 'settings.telemetry':'Telemetry', 'settings.telemetry.enabled':'Enable Anonymous Telemetry', 'settings.telemetry.hint':'Only collects tool names, latencies and version info. No memory content or personal data.', + 'settings.export':'Export Data', + 'settings.export.hint':'Download all your memories, tasks, and skills as a backup file.', + 'settings.export.json':'Export JSON (full backup)', + 'settings.export.csv':'Export CSV (memories only)', 'settings.viewerport':'Viewer Port', 'settings.viewerport.hint':'Requires restart to take effect', 'settings.taskAutoFinalize':'Task Auto-Finalize (hours)', @@ -3100,6 +3111,10 @@ const I18N={ 'settings.telemetry':'数据统计', 'settings.telemetry.enabled':'启用匿名数据统计', 'settings.telemetry.hint':'仅收集工具名称、响应时间和版本号,不涉及任何记忆内容或个人数据。', + 'settings.export':'导出数据', + 'settings.export.hint':'将所有记忆、任务和技能下载为备份文件。', + 'settings.export.json':'导出 JSON(完整备份)', + 'settings.export.csv':'导出 CSV(仅记忆)', 'settings.viewerport':'Viewer 端口', 'settings.viewerport.hint':'修改后需重启网关生效', 'settings.taskAutoFinalize':'任务自动完结(小时)', @@ -7493,6 +7508,16 @@ async function saveHubConfig(){ } } +function exportData(format){ + var url='/api/export?format='+encodeURIComponent(format); + var a=document.createElement('a'); + a.href=url; + a.style.display='none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} + async function saveGeneralConfig(){ var card=document.querySelector('.card-general'); var saveBtn=card.querySelector('.settings-actions .btn-primary'); diff --git a/apps/memos-local-openclaw/src/viewer/server.ts b/apps/memos-local-openclaw/src/viewer/server.ts index 852f3eda..5ef1acef 100644 --- a/apps/memos-local-openclaw/src/viewer/server.ts +++ b/apps/memos-local-openclaw/src/viewer/server.ts @@ -395,6 +395,7 @@ export class ViewerServer { else if (p === "/api/migrate/postprocess/stream" && req.method === "GET") this.handlePostprocessStream(res); else if (p === "/api/migrate/postprocess/stop" && req.method === "POST") this.handlePostprocessStop(res); else if (p === "/api/migrate/postprocess/status" && req.method === "GET") this.handlePostprocessStatus(res); + else if (p === "/api/export" && req.method === "GET") this.handleExport(res, url); else { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "not found" })); @@ -3014,6 +3015,42 @@ export class ViewerServer { res.end(JSON.stringify({ ips })); } + // ─── Export ─── + + private handleExport(res: http.ServerResponse, url: URL): void { + const format = url.searchParams.get("format") ?? "json"; + const now = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + + try { + if (format === "csv") { + const csv = this.store.exportMemoriesAsCsv(); + const filename = `memos-memories-${now}.csv`; + res.writeHead(200, { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="${filename}"`, + }); + res.end(csv); + } else { + const data = this.store.exportAll(); + const payload = JSON.stringify( + { exportedAt: new Date().toISOString(), version: 1, ...data }, + null, + 2, + ); + const filename = `memos-export-${now}.json`; + res.writeHead(200, { + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="${filename}"`, + }); + res.end(payload); + } + } catch (err) { + this.log.error(`Export failed: ${err}`); + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: String(err) })); + } + } + private serveConfig(res: http.ServerResponse): void { try { const cfgPath = this.getOpenClawConfigPath();