dsh-archived-chats 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,19 @@ Restart DSH once after installing, then open **Settings → Archived Chats**.
16
16
 
17
17
  ## Compatibility
18
18
 
19
- Version 0.7.0 is tested against DeepSeek Harness `0.1.0-rc.7`. The plugin registers a top-level `settings.section`, so the rc.7 keyed-slot change for `settings.plugin.item` does not apply to it. Future Harness releases should still be checked with the smoke suite and a real-host UI pass before publishing a plugin update, because client slot and design-token contracts may evolve.
19
+ Version 0.8.0 uses DeepSeek Harness `0.1.0-rc.7` as its automated compatibility baseline. The plugin registers a top-level `settings.section`, so the rc.7 keyed-slot change for `settings.plugin.item` does not apply to it. A local real-host UI pass was also completed on Harness `0.1.0-rc.8` for the archive list, search, metadata editor, bulk actions, group actions, and import preview. Future Harness releases should still be checked with the smoke suite and a real-host UI pass before publishing a plugin update, because client slot and design-token contracts may evolve.
20
+
21
+ ## Screenshots
22
+
23
+ These screenshots were captured from the current 0.8.0 build in a local DeepSeek Harness web profile.
24
+
25
+ ![Archived Chats overview](assets/screenshots/1-archived-chats.png)
26
+ ![Search and filters](assets/screenshots/2-search.png)
27
+ ![Delete confirmation](assets/screenshots/3-delete-confirm.png)
28
+ ![Group actions](assets/screenshots/4-group-menu.png)
29
+ ![Metadata editor](assets/screenshots/5-metadata-editor.png)
30
+ ![Bulk actions](assets/screenshots/6-bulk-actions.png)
31
+ ![Import preview](assets/screenshots/7-import-preview.png)
20
32
 
21
33
  ## Features
22
34
 
@@ -25,6 +37,7 @@ Version 0.7.0 is tested against DeepSeek Harness `0.1.0-rc.7`. The plugin regist
25
37
  - **Tags and notes**: open an editor from any row to attach up to 8 tags (24 Unicode characters each) and a note (2,000 Unicode characters). Tag chips render per row, overflowing past three into a `+N` indicator, and the tag filter narrows the list case-insensitively.
26
38
  - **Storage insights**: a summary strip reports the archived count, total measured size, and how many sessions could not be measured; each row shows its own size. Measurement never follows symbolic links and skips sessions whose directories are unreadable.
27
39
  - **JSON + Markdown backups**: export one row, the current selection, or every archived chat as a ZIP. Each package has a versioned manifest, a lossless machine-readable session record, and a human-readable transcript for every included session.
40
+ - **Preview-first import and restore**: choose a ZIP backup, inspect every session before writing, preselect only non-conflicting IDs, and restore selected sessions as archived chats. Existing IDs are skipped and never overwritten.
28
41
  - **Flexible multi-select**: select individual chats, every visible result, or an entire project. The selection bar can export, unarchive, or permanently delete the chosen chats in one action, while selections hidden by another filter remain intact.
29
42
  - **Unarchive** a single chat or a whole project group from the group's `⋯` menu — restored chats reappear in the sidebar immediately.
30
43
  - **Delete** one chat, a project group, or everything (**Delete All**), each behind a confirmation dialog. Deletion is thorough: the session log is removed from disk, the session is detached from its workspace record, and the registry's in-memory header index is purged, so the sidebar drops the rows live.
@@ -47,11 +60,15 @@ sessions/001-<safe-title>-<id>/transcript.md
47
60
 
48
61
  `session.json` is the authoritative backup record: it contains the complete metadata and event values returned by Harness persistence plus the archive title, workspace, timestamps, origin, tags, note, and storage facts. `transcript.md` is a readable companion derived with Harness's canonical message projection. ZIP paths are sanitized and collision-safe, and batches are generated one session at a time instead of buffering every transcript together.
49
62
 
50
- Attachment references remain in JSON, but **attachment bytes and descendant sessions are not included**. Use Harness's official Session log export when you need its attachment-complete conversation-tree package. Version 0.7.0 exports only; validated import/restore and conflict handling are planned for 0.8.0.
63
+ Attachment references remain in JSON, but **attachment bytes and descendant sessions are not included**. Use Harness's official Session log export when you need its attachment-complete conversation-tree package.
64
+
65
+ ## Import and restore
66
+
67
+ Import accepts only this plugin's version-one export ZIPs. The browser first uploads the package for bounded validation and shows a preview containing titles, workspaces, tags, notes, storage facts, ID conflicts, unresolved-workspace warnings, and attachment-reference warnings; raw events and Markdown are never rendered in the preview. Existing session IDs are disabled and skipped, and unresolved workspaces are restored ungrouped. A confirmation token expires after 10 minutes and can be used once. Tags and notes are restored through the same local metadata limits as manual edits. No attachment bytes are restored. Hosts without the supported Harness writer capability return `restore-unsupported` without writing anything.
51
68
 
52
69
  ## How it works
53
70
 
54
- - **Host half** (`lib/index.js`) registers the `/plugins/dsh-archived-chats/*` routes on the DSH web server: `GET /state`, `GET /stats`, `POST /export`, `POST /metadata`, `POST /unarchive`, `POST /unarchive-all`, `POST /delete`, `POST /delete-all`. `/state` joins tags, notes, and `metadataUpdatedAt` onto every row; `/stats` returns byte/file totals; `/export` streams a ZIP response from a bounded native-form request. Unarchiving writes through the workspace registry's own state path, so every connected client receives the `host/archived-sessions-changed` push. Mutating routes require a custom `x-dsh-archived-chats: 1` header as CSRF hardening; read-only export does not mutate plugin or Harness state.
71
+ - **Host half** (`lib/index.js`) registers the `/plugins/dsh-archived-chats/*` routes on the DSH web server: `GET /state`, `GET /stats`, `POST /export`, `POST /import/inspect`, `POST /import/restore`, `POST /metadata`, `POST /unarchive`, `POST /unarchive-all`, `POST /delete`, `POST /delete-all`. `/state` joins tags, notes, and `metadataUpdatedAt` onto every row; `/stats` returns byte/file totals; `/export` streams a ZIP response from a bounded native-form request; the import routes validate a bounded multipart ZIP, keep a short-lived single-use preview token, and commit through the feature-detected restore adapter. Unarchiving writes through the workspace registry's own state path, so every connected client receives the `host/archived-sessions-changed` push. Mutating routes require a custom `x-dsh-archived-chats: 1` header as CSRF hardening; read-only export does not mutate plugin or Harness state.
55
72
  - **Export writer** (`lib/export.js`): owns format-versioned records, safe filenames, Harness transcript projection, and sequential ZIP entries. It preflights the first session before response headers and keeps at most one inspected session payload during a batch.
56
73
  - **Metadata store** (`lib/metadata.js`): a versioned, atomic JSON store. Writes serialize through a queue and replace the file via a temp-file rename, so simultaneous saves cannot interleave; unreadable or unsupported files are never overwritten.
57
74
  - **Storage statistics** (`lib/stats.js`): measures session directories at concurrency 4, skips symbolic links, caches results for 30 seconds, and reports unavailable rows instead of failing the request. Delete invalidates the cached row.
@@ -66,7 +83,7 @@ Attachment references remain in JSON, but **attachment bytes and descendant sess
66
83
  npm test
67
84
  ```
68
85
 
69
- The suite (`test/*.test.mjs`) covers export records and real ZIP decoding, the metadata store, the statistics service, and host-and-browser smoke tests. It uses an isolated temporary DSH home plus mocked host and browser runtimes; it never reads or changes real sessions.
86
+ The suite (`test/*.test.mjs`) covers export records and real ZIP decoding, bounded import validation, restore transactions, the metadata store, the statistics service, and host-and-browser smoke tests. It uses an isolated temporary DSH home plus mocked host and browser runtimes; it never reads or changes real sessions.
70
87
 
71
88
  ## Uninstall
72
89
 
package/README.zh.md CHANGED
@@ -16,7 +16,19 @@ dsh plugin --profile web add dsh-archived-chats
16
16
 
17
17
  ## 兼容性
18
18
 
19
- 0.7.0 版本以 DeepSeek Harness `0.1.0-rc.7` 为验证基线。插件注册的是顶层 `settings.section`,因此 rc.7 针对 `settings.plugin.item` 的 keyed-slot 变更不影响本插件。以后 Harness 发布新版本时,仍应在发布插件更新前重跑冒烟测试并检查真实宿主页面,因为客户端插槽和设计令牌契约仍可能演进。
19
+ 0.8.0 版本以 DeepSeek Harness `0.1.0-rc.7` 作为自动化兼容性基线。插件注册的是顶层 `settings.section`,因此 rc.7 针对 `settings.plugin.item` 的 keyed-slot 变更不影响本插件;同时已在 Harness `0.1.0-rc.8` 上完成真实宿主页面复核,覆盖归档列表、搜索、元数据编辑、批量操作、分组操作和导入预览。以后 Harness 发布新版本时,仍应在发布插件更新前重跑冒烟测试并检查真实宿主页面,因为客户端插槽和设计令牌契约仍可能演进。
20
+
21
+ ## 截图
22
+
23
+ 以下截图来自当前 `0.8.0` 构建,并在本地 DeepSeek Harness Web profile 中实际操作生成。
24
+
25
+ ![已归档的聊天总览](assets/screenshots/1-archived-chats.png)
26
+ ![搜索与筛选](assets/screenshots/2-search.png)
27
+ ![删除确认](assets/screenshots/3-delete-confirm.png)
28
+ ![分组操作](assets/screenshots/4-group-menu.png)
29
+ ![标签与备注编辑](assets/screenshots/5-metadata-editor.png)
30
+ ![批量操作](assets/screenshots/6-bulk-actions.png)
31
+ ![导入预览](assets/screenshots/7-import-preview.png)
20
32
 
21
33
  ## 功能
22
34
 
@@ -25,6 +37,7 @@ dsh plugin --profile web add dsh-archived-chats
25
37
  - **标签与备注**:任意行打开编辑器即可添加最多 8 个标签(每个最多 24 个 Unicode 字符)和一条备注(最多 2,000 个 Unicode 字符)。每行渲染标签小徽章,超过 3 个折叠为 `+N`,标签筛选不区分大小写。
26
38
  - **存储统计**:概览条显示归档数量、已统计总大小与无法统计的会话数;每行显示各自占用。统计不会跟随符号链接,无法读取的会话目录显示为「无法统计」而非让请求失败。
27
39
  - **JSON + Markdown 备份**:可导出单条、当前选中项或全部归档会话。每个 ZIP 都包含带版本的清单、用于机器恢复的完整会话 JSON,以及方便阅读的 Markdown 对话稿。
40
+ - **预览后导入与恢复**:选择 ZIP 备份后先检查全部会话,默认选中无冲突 ID 的项目,确认后作为已归档聊天恢复。已有 ID 会跳过,绝不会覆盖。
28
41
  - **灵活多选**:逐条选择、选择当前筛选结果或选择整个项目;选中后可一次导出、取消归档或永久删除,隐藏在其他筛选结果中的选择不会丢失。
29
42
  - **取消归档**单个聊天,或从分组的 `⋯` 菜单整组取消——恢复的聊天会立刻回到侧边栏。
30
43
  - **删除**单个聊天、某个项目分组或全部(**全部删除**),均有确认弹窗。删除是彻底的:会话日志从磁盘移除、从工作区记录中摘除、注册表内存索引同步清理,主侧边栏的条目也会立即消失。
@@ -47,11 +60,15 @@ sessions/001-<安全标题>-<id>/transcript.md
47
60
 
48
61
  `session.json` 是权威备份记录:原样保存 Harness 持久层返回的完整元数据和事件,并附带归档标题、工作区、时间、来源、标签、备注和存储统计。`transcript.md` 是通过 Harness 官方消息投影生成的可读副本。ZIP 路径会净化并处理重名,批量导出逐个会话生成,不会同时把所有会话内容堆进内存。
49
62
 
50
- JSON 会保留附件引用,但**本版不复制附件二进制,也不包含子会话**。需要带完整附件的会话树时,请使用 Harness 官方的 Session log 导出。0.7.0 只负责导出;经过校验的导入/恢复与冲突策略留到 0.8.0。
63
+ JSON 会保留附件引用,但**本版不复制附件二进制,也不包含子会话**。需要带完整附件的会话树时,请使用 Harness 官方的 Session log 导出。
64
+
65
+ ## 导入与恢复
66
+
67
+ 导入只接受本插件版本一的导出 ZIP。浏览器会先上传并进行有界校验,然后展示标题、项目、标签、备注、存储信息、ID 冲突、项目不存在警告和附件引用警告;预览不会渲染原始事件或 Markdown。已有会话 ID 会被禁用并跳过,找不到的项目会恢复为未分组。确认令牌 10 分钟后过期且只能使用一次。标签和备注通过现有本地元数据限制恢复,不会恢复附件二进制。宿主没有可用的 Harness 写入能力时返回 `restore-unsupported`,不会写入任何数据。
51
68
 
52
69
  ## 实现原理
53
70
 
54
- - **Host 半**(`lib/index.js`)在 DSH Web 服务器上注册 `/plugins/dsh-archived-chats/*` 路由:`GET /state`、`GET /stats`、`POST /export`、`POST /metadata`、`POST /unarchive`、`POST /unarchive-all`、`POST /delete`、`POST /delete-all`。`/state` 拼接标签与备注,`/stats` 返回字节数/文件数,`/export` 从有界的原生表单请求流式返回 ZIP。取消归档走 workspace registry 自身的状态写入通道,所有已连接的客户端都会收到 `host/archived-sessions-changed` 推送。会改变状态的路由要求 `x-dsh-archived-chats: 1` 作为 CSRF 加固;只读导出不会修改插件或 Harness 状态。
71
+ - **Host 半**(`lib/index.js`)在 DSH Web 服务器上注册 `/plugins/dsh-archived-chats/*` 路由:`GET /state`、`GET /stats`、`POST /export`、`POST /import/inspect`、`POST /import/restore`、`POST /metadata`、`POST /unarchive`、`POST /unarchive-all`、`POST /delete`、`POST /delete-all`。`/state` 拼接标签与备注,`/stats` 返回字节数/文件数,`/export` 从有界的原生表单请求流式返回 ZIP;导入路由对 multipart ZIP 做有界校验,使用短期一次性预览令牌,并通过能力探测的恢复适配器提交。取消归档走 workspace registry 自身的状态写入通道,所有已连接的客户端都会收到 `host/archived-sessions-changed` 推送。会改变状态的路由要求 `x-dsh-archived-chats: 1` 作为 CSRF 加固;只读导出不会修改插件或 Harness 状态。
55
72
  - **导出生成器**(`lib/export.js`):负责带版本的备份记录、安全文件名、Harness 对话投影和顺序 ZIP 条目。首个会话会在发送响应头前预检,批量过程中最多保留一个已检查会话的载荷。
56
73
  - **元数据存储**(`lib/metadata.js`):带版本号的原子 JSON 存储。写入通过队列串行化,并以临时文件重命名的方式替换原文件,因此并发保存不会互相交叠;无法读取或不支持的版本绝不被覆盖。
57
74
  - **存储统计**(`lib/stats.js`):以并发 4 测量会话目录,跳过符号链接,结果缓存 30 秒,无法统计的会话上报为「不可用」而不是让请求失败。删除会使对应缓存失效。
@@ -66,7 +83,7 @@ JSON 会保留附件引用,但**本版不复制附件二进制,也不包含
66
83
  npm test
67
84
  ```
68
85
 
69
- 测试套件(`test/*.test.mjs`)覆盖导出记录与真实 ZIP 解包、元数据存储、统计服务以及宿主+浏览器冒烟测试,使用隔离的临时 DSH 主目录和模拟运行时,不会读取或修改真实会话。
86
+ 测试套件(`test/*.test.mjs`)覆盖导出记录与真实 ZIP 解包、有界导入校验、恢复事务、元数据存储、统计服务以及宿主+浏览器冒烟测试,使用隔离的临时 DSH 主目录和模拟运行时,不会读取或修改真实会话。
70
87
 
71
88
  ## 卸载
72
89
 
Binary file
package/lib/client.js CHANGED
@@ -49,6 +49,22 @@ window.__ModuleLoader__.load({
49
49
  "nav": "已归档的聊天",
50
50
  "page.title": "已归档的聊天",
51
51
  "delete.all": "全部删除",
52
+ "import.action": "导入备份",
53
+ "import.title": "导入归档备份",
54
+ "import.inspecting": "正在检查备份…",
55
+ "import.preview": "恢复前请确认要导入的会话",
56
+ "import.package": "备份信息",
57
+ "import.confirm": "恢复选中项",
58
+ "import.cancel": "取消",
59
+ "import.selectAll": "全选可恢复项",
60
+ "import.clear": "清除选择",
61
+ "import.conflict": "ID 冲突,将跳过",
62
+ "import.workspaceWarning": "项目不存在,将保持未分组",
63
+ "import.attachmentWarning": "包含附件引用,但不会恢复附件文件",
64
+ "import.restored": "已恢复",
65
+ "import.skipped": "已跳过",
66
+ "import.expired": "预览已过期,请重新选择备份",
67
+ "import.done": "备份恢复完成",
52
68
  "export.all": "全部导出",
53
69
  "export.selected": "导出选中项",
54
70
  "export.row": "导出备份",
@@ -105,6 +121,22 @@ window.__ModuleLoader__.load({
105
121
  "nav": "Archived Chats",
106
122
  "page.title": "Archived Chats",
107
123
  "delete.all": "Delete All",
124
+ "import.action": "Import backup",
125
+ "import.title": "Import archived backup",
126
+ "import.inspecting": "Inspecting backup…",
127
+ "import.preview": "Review the sessions before restoring",
128
+ "import.package": "Backup details",
129
+ "import.confirm": "Restore selected",
130
+ "import.cancel": "Cancel",
131
+ "import.selectAll": "Select restorable",
132
+ "import.clear": "Clear selection",
133
+ "import.conflict": "ID conflict, will be skipped",
134
+ "import.workspaceWarning": "Workspace is missing; will stay ungrouped",
135
+ "import.attachmentWarning": "Attachment references found; attachment files are not restored",
136
+ "import.restored": "Restored",
137
+ "import.skipped": "Skipped",
138
+ "import.expired": "The preview expired. Choose the backup again",
139
+ "import.done": "Backup restore completed",
108
140
  "export.all": "Export all",
109
141
  "export.selected": "Export selected",
110
142
  "export.row": "Export backup",
@@ -331,6 +363,9 @@ window.__ModuleLoader__.load({
331
363
  .dac-exportall{display:inline-flex;align-items:center;gap:6px;border:none;border-radius:999px;padding:6px 12px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background .15s,color .15s}
332
364
  .dac-exportall:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary)}
333
365
  .dac-exportall:disabled{opacity:.45;cursor:default}
366
+ .dac-import{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:5px 11px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;cursor:pointer}
367
+ .dac-import:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
368
+ .dac-import:disabled{opacity:.45;cursor:default}
334
369
  .dac-deleteall{display:inline-flex;align-items:center;gap:6px;border:none;border-radius:999px;padding:6px 14px;background:transparent;color:var(--dsw-alias-state-error-primary);font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background .15s}
335
370
  .dac-deleteall:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}
336
371
  .dac-deleteall:disabled{opacity:.45;cursor:default}
@@ -418,6 +453,17 @@ window.__ModuleLoader__.load({
418
453
  .dac-chip-more{color:var(--dsw-alias-label-tertiary)}
419
454
  .dac-row-size{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;flex:none}
420
455
  .dac-meta-dialog{width:min(440px,calc(100vw - 64px))}
456
+ .dac-import-dialog{width:min(560px,calc(100vw - 48px));max-height:min(700px,calc(100vh - 48px));overflow:auto}
457
+ .dac-import-package{display:grid;grid-template-columns:auto 1fr;gap:4px 10px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
458
+ .dac-import-package strong{grid-column:1/-1;color:var(--dsw-alias-label-primary);font-size:13px}
459
+ .dac-import-list{display:flex;flex-direction:column;gap:8px;max-height:360px;overflow:auto}
460
+ .dac-import-selection{display:flex;justify-content:flex-end;gap:6px;flex-wrap:wrap}
461
+ .dac-import-row{display:flex;align-items:flex-start;gap:9px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;padding:8px 10px;cursor:pointer}
462
+ .dac-import-row.dac-import-conflict{opacity:.58;cursor:default}
463
+ .dac-import-main{display:flex;flex-direction:column;gap:2px;min-width:0;color:var(--dsw-alias-label-primary);font-size:13px;line-height:18px}
464
+ .dac-import-main span{color:var(--dsw-alias-label-tertiary);font-size:11px}
465
+ .dac-import-main small{color:var(--dsw-alias-state-error-primary);font-size:11px;line-height:16px}
466
+ .dac-import-result{display:flex;gap:14px;color:var(--dsw-alias-state-success-primary);font-size:13px;line-height:20px}
421
467
  .dac-field-label{display:block;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
422
468
  .dac-meta-input,.dac-meta-note{width:100%;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;padding:7px 10px;outline:none}
423
469
  .dac-meta-input:focus,.dac-meta-note:focus{border-color:var(--dsw-alias-border-l1,var(--dsw-alias-border-l2))}
@@ -535,6 +581,24 @@ window.__ModuleLoader__.load({
535
581
  return post("/metadata", { sessionId, tags, note });
536
582
  }
537
583
 
584
+ async function submitImportFile(file) {
585
+ if (file === null || file === undefined) throw new Error("backup file is required");
586
+ const body = new FormData();
587
+ body.append("file", file, file.name || "backup.zip");
588
+ const res = await fetch(`${API_BASE}/import/inspect`, {
589
+ method: "POST",
590
+ headers: { [GUARD_HEADER]: "1" },
591
+ body,
592
+ });
593
+ const parsed = await res.json().catch(() => ({}));
594
+ if (!res.ok) {
595
+ const error = new Error(parsed.message || parsed.error || `HTTP ${res.status}`);
596
+ error.body = parsed;
597
+ throw error;
598
+ }
599
+ return parsed;
600
+ }
601
+
538
602
  function submitExport(sessionIds) {
539
603
  if (typeof document === "undefined") return false;
540
604
  const ids = [...new Set((Array.isArray(sessionIds) ? sessionIds : [])
@@ -603,6 +667,9 @@ window.__ModuleLoader__.load({
603
667
  function IconDownload({ size = 15 }) {
604
668
  return (0, jsx.jsxs)("svg", { ...svgProps(size), children: [(0, jsx.jsx)("path", { d: "M12 3v12" }), (0, jsx.jsx)("path", { d: "m7 10 5 5 5-5" }), (0, jsx.jsx)("path", { d: "M5 20h14" })] });
605
669
  }
670
+ function IconUpload({ size = 15 }) {
671
+ return (0, jsx.jsxs)("svg", { ...svgProps(size), children: [(0, jsx.jsx)("path", { d: "M12 21V9" }), (0, jsx.jsx)("path", { d: "m7 14 5-5 5 5" }), (0, jsx.jsx)("path", { d: "M5 3h14" })] });
672
+ }
606
673
  const EDIT_ICON_SPEC = {
607
674
  size: 16,
608
675
  viewBox: "0 0 1024 1024",
@@ -841,6 +908,85 @@ window.__ModuleLoader__.load({
841
908
  });
842
909
  }
843
910
 
911
+ function importWarningText(t, warning) {
912
+ if (warning === "workspace-unresolved") return t("import.workspaceWarning");
913
+ if (warning === "attachment-references" || warning === "attachments-not-included") return t("import.attachmentWarning");
914
+ return warning;
915
+ }
916
+
917
+ function ImportDialog({ preview, t, busy, onToggle, onSelectAll, onClear, onConfirm, onCancel }) {
918
+ const dialogRef = _react.useRef(null);
919
+ const cancelRef = _react.useRef(null);
920
+ const sessions = Array.isArray(preview?.sessions) ? preview.sessions : [];
921
+ const selected = new Set(preview?.selectedIds ?? []);
922
+ const eligible = sessions.filter((session) => session.conflict !== true);
923
+ const allSelected = eligible.length > 0 && eligible.every((session) => selected.has(session.id));
924
+ _react.useEffect(() => {
925
+ (cancelRef.current ?? dialogRef.current)?.focus?.();
926
+ const onKey = (event) => {
927
+ if (event.key === "Escape") { event.preventDefault?.(); onCancel(); }
928
+ };
929
+ document.addEventListener("keydown", onKey);
930
+ return () => document.removeEventListener("keydown", onKey);
931
+ }, [onCancel]);
932
+ const result = preview?.result;
933
+ const generator = preview?.package?.generator;
934
+ const generatorName = typeof generator === "string" ? generator : generator?.name ?? "dsh-archived-chats";
935
+ const generatorVersion = typeof generator === "object" ? generator?.version : undefined;
936
+ const packageVersion = preview?.package?.version ?? "1";
937
+ const packageLabel = generatorVersion
938
+ ? `${generatorName} v${generatorVersion} · format v${packageVersion}`
939
+ : `${generatorName} · v${packageVersion}`;
940
+ return (0, jsx.jsx)("div", {
941
+ className: "dac-confirm-overlay",
942
+ onClick: onCancel,
943
+ children: (0, jsx.jsxs)("div", {
944
+ ref: dialogRef,
945
+ className: "dac-confirm dac-import-dialog",
946
+ role: "dialog",
947
+ "aria-modal": "true",
948
+ "aria-labelledby": "dac-import-title",
949
+ "aria-describedby": "dac-import-description",
950
+ tabIndex: -1,
951
+ onClick: (event) => event.stopPropagation(),
952
+ children: [
953
+ (0, jsx.jsx)("div", { id: "dac-import-title", className: "dac-confirm-title", children: t("import.title") }),
954
+ (0, jsx.jsx)("div", { id: "dac-import-description", className: "dac-confirm-body", children: result ? t("import.done") : t("import.preview") }),
955
+ !result && (0, jsx.jsxs)("div", { className: "dac-import-package", children: [
956
+ (0, jsx.jsx)("strong", { children: t("import.package") }),
957
+ (0, jsx.jsx)("span", { children: packageLabel }),
958
+ (0, jsx.jsx)("span", { children: `${preview?.package?.sessionCount ?? sessions.length} ${isZh(t) ? "个会话" : "sessions"}` }),
959
+ ] }),
960
+ result ? (0, jsx.jsxs)("div", { className: "dac-import-result", children: [
961
+ (0, jsx.jsx)("span", { children: `${t("import.restored")}: ${(result.restored ?? []).length}` }),
962
+ (0, jsx.jsx)("span", { children: `${t("import.skipped")}: ${(result.skipped ?? []).length}` }),
963
+ ] }) : (0, jsx.jsxs)("div", { className: "dac-import-list", children: [
964
+ (0, jsx.jsxs)("div", { className: "dac-import-selection", children: [
965
+ (0, jsx.jsx)("button", { type: "button", className: "dac-btn", onClick: onSelectAll, disabled: allSelected || busy, children: t("import.selectAll") }),
966
+ (0, jsx.jsx)("button", { type: "button", className: "dac-btn", onClick: onClear, disabled: selected.size === 0 || busy, children: t("import.clear") }),
967
+ ] }),
968
+ sessions.map((session) => {
969
+ const warnings = Array.isArray(session.warnings) ? session.warnings : [];
970
+ return (0, jsx.jsxs)("label", { className: session.conflict ? "dac-import-row dac-import-conflict" : "dac-import-row", children: [
971
+ (0, jsx.jsx)("input", { type: "checkbox", className: "dac-checkbox", checked: selected.has(session.id), disabled: session.conflict === true || busy, onChange: (event) => onToggle(session.id, event.target.checked) }),
972
+ (0, jsx.jsxs)("span", { className: "dac-import-main", children: [
973
+ (0, jsx.jsx)("strong", { children: session.title || t("chat.untitled") }),
974
+ (0, jsx.jsx)("span", { children: session.workspace?.title || t("group.noProject") }),
975
+ ...(session.conflict ? [(0, jsx.jsx)("small", { children: t("import.conflict") })] : []),
976
+ ...warnings.map((warning) => (0, jsx.jsx)("small", { children: importWarningText(t, warning) }, `${session.id}-${warning}`)),
977
+ ] }),
978
+ ] }, session.id);
979
+ })
980
+ ] }),
981
+ (0, jsx.jsxs)("div", { className: "dac-confirm-actions", children: [
982
+ (0, jsx.jsx)("button", { ref: cancelRef, type: "button", className: "dac-btn", onClick: onCancel, children: t("import.cancel") }),
983
+ !result && (0, jsx.jsx)("button", { type: "button", className: "dac-btn-primary", disabled: busy || selected.size === 0, onClick: onConfirm, children: busy ? t("import.inspecting") : t("import.confirm") }),
984
+ ] })
985
+ ]
986
+ })
987
+ });
988
+ }
989
+
844
990
  function GroupSection({ group, t, collapsed, onToggleCollapsed, menuOpen, onToggleMenu, onUnarchive, onDelete, onExport, busy, selected, onToggleSelected, stats, metadataStatus, onEditMetadata }) {
845
991
  const wrapRef = _react.useRef(null);
846
992
  _react.useEffect(() => {
@@ -1011,12 +1157,15 @@ window.__ModuleLoader__.load({
1011
1157
  const [tagFilter, setTagFilter] = _react.useState("");
1012
1158
  const [metadataStatus, setMetadataStatus] = _react.useState("ready");
1013
1159
  const [stats, setStats] = _react.useState({ status: "idle", summary: null, sessions: {} });
1014
- const [metadataEdit, setMetadataEdit] = _react.useState(null);
1015
- const [metaBusy, setMetaBusy] = _react.useState(false);
1016
- const pageRef = _react.useRef(null);
1160
+ const [metadataEdit, setMetadataEdit] = _react.useState(null);
1161
+ const [metaBusy, setMetaBusy] = _react.useState(false);
1162
+ const [importPreview, setImportPreview] = _react.useState(null);
1163
+ const [importBusy, setImportBusy] = _react.useState(false);
1164
+ const pageRef = _react.useRef(null);
1017
1165
  const pageHeadingRef = _react.useRef(null);
1018
1166
  const deleteReturnFocusRef = _react.useRef(null);
1019
- const metaReturnFocusRef = _react.useRef(null);
1167
+ const metaReturnFocusRef = _react.useRef(null);
1168
+ const importInputRef = _react.useRef(null);
1020
1169
 
1021
1170
  _react.useEffect(() => {
1022
1171
  if (sessions === null || loadError !== null) return undefined;
@@ -1209,6 +1358,57 @@ window.__ModuleLoader__.load({
1209
1358
  setNotice({ kind: "ok", text: t("export.started") });
1210
1359
  };
1211
1360
 
1361
+ const importFile = async (file) => {
1362
+ if (!file) return;
1363
+ setImportBusy(true);
1364
+ setNotice(null);
1365
+ try {
1366
+ const preview = await submitImportFile(file);
1367
+ const sessions = Array.isArray(preview.sessions) ? preview.sessions : [];
1368
+ setImportPreview({
1369
+ ...preview,
1370
+ sessions,
1371
+ selectedIds: sessions.filter((session) => session.conflict !== true).map((session) => session.id),
1372
+ result: null,
1373
+ });
1374
+ } catch (error) {
1375
+ const code = error.body?.error;
1376
+ setNotice({ kind: "error", text: code === "import-token-invalid" ? t("import.expired") : String(error.message ?? error) });
1377
+ } finally {
1378
+ setImportBusy(false);
1379
+ }
1380
+ };
1381
+ const restoreImport = async () => {
1382
+ if (!importPreview || importPreview.selectedIds.length === 0) return;
1383
+ setImportBusy(true);
1384
+ try {
1385
+ const result = await post("/import/restore", {
1386
+ token: importPreview.token,
1387
+ nonce: importPreview.nonce,
1388
+ sessionIds: importPreview.selectedIds,
1389
+ });
1390
+ setImportPreview((current) => current ? { ...current, result } : current);
1391
+ await refresh();
1392
+ refreshSidebar?.();
1393
+ setNotice({ kind: "ok", text: t("import.done") });
1394
+ } catch (error) {
1395
+ const code = error.body?.error;
1396
+ setNotice({ kind: "error", text: code === "import-token-invalid" ? t("import.expired") : String(error.message ?? error) });
1397
+ } finally {
1398
+ setImportBusy(false);
1399
+ }
1400
+ };
1401
+ const toggleImportSelection = (id, checked) => {
1402
+ setImportPreview((current) => {
1403
+ if (!current) return current;
1404
+ const next = new Set(current.selectedIds ?? []);
1405
+ if (checked) next.add(id); else next.delete(id);
1406
+ return { ...current, selectedIds: [...next] };
1407
+ });
1408
+ };
1409
+ const selectAllImport = () => setImportPreview((current) => current ? { ...current, selectedIds: current.sessions.filter((session) => session.conflict !== true).map((session) => session.id) } : current);
1410
+ const clearImport = () => setImportPreview((current) => current ? { ...current, selectedIds: [] } : current);
1411
+
1212
1412
  const projects = _react.useMemo(() => {
1213
1413
  const seen = new Map();
1214
1414
  for (const s of sessions ?? []) {
@@ -1303,7 +1503,14 @@ window.__ModuleLoader__.load({
1303
1503
  (0, jsx.jsxs)("div", {
1304
1504
  className: "dac-head-actions",
1305
1505
  children: [
1306
- selectedIds.length === 0 && (0, jsx.jsxs)("button", {
1506
+ (0, jsx.jsxs)("button", {
1507
+ type: "button",
1508
+ className: "dac-import",
1509
+ disabled: importBusy,
1510
+ onClick: () => importInputRef.current?.click?.(),
1511
+ children: [(0, jsx.jsx)(IconUpload, {}), (0, jsx.jsx)("span", { children: t("import.action") })]
1512
+ }),
1513
+ selectedIds.length === 0 && (0, jsx.jsxs)("button", {
1307
1514
  type: "button",
1308
1515
  className: "dac-exportall",
1309
1516
  disabled: allIds.length === 0 || allBusy,
@@ -1317,11 +1524,18 @@ window.__ModuleLoader__.load({
1317
1524
  onClick: () => askDelete(allIds, void 0),
1318
1525
  children: [(0, jsx.jsx)(IconTrash, {}), (0, jsx.jsx)("span", { children: t("delete.all") })]
1319
1526
  })
1320
- ]
1321
- })
1322
- ]
1323
- }),
1324
- (0, jsx.jsxs)("div", {
1527
+ ]
1528
+ }),
1529
+ (0, jsx.jsx)("input", {
1530
+ ref: importInputRef,
1531
+ type: "file",
1532
+ accept: ".zip,application/zip",
1533
+ hidden: true,
1534
+ onChange: (event) => { const file = event.target.files?.[0]; void importFile(file); event.target.value = ""; }
1535
+ }),
1536
+ ]
1537
+ }),
1538
+ (0, jsx.jsxs)("div", {
1325
1539
  className: "dac-summary",
1326
1540
  role: "status",
1327
1541
  children: [
@@ -1485,8 +1699,18 @@ window.__ModuleLoader__.load({
1485
1699
  stats,
1486
1700
  metadataStatus,
1487
1701
  onEditMetadata: openMetadataEditor
1488
- }, group.key)),
1489
- metadataEdit !== null && (0, jsx.jsx)(MetadataDialog, {
1702
+ }, group.key)),
1703
+ importPreview !== null && (0, jsx.jsx)(ImportDialog, {
1704
+ preview: importPreview,
1705
+ t,
1706
+ busy: importBusy,
1707
+ onToggle: toggleImportSelection,
1708
+ onSelectAll: selectAllImport,
1709
+ onClear: clearImport,
1710
+ onConfirm: restoreImport,
1711
+ onCancel: () => setImportPreview(null),
1712
+ }),
1713
+ metadataEdit !== null && (0, jsx.jsx)(MetadataDialog, {
1490
1714
  session: metadataEdit,
1491
1715
  t,
1492
1716
  busy: metaBusy,
@@ -1541,7 +1765,7 @@ window.__ModuleLoader__.load({
1541
1765
  //#endregion
1542
1766
 
1543
1767
  exports.SETTINGS_NS = SETTINGS_NS;
1544
- exports.__test = { formatBytes, matchesArchivedSession, filterByTag, sortArchivedSessions, setVisibleSelection, reconcileSelection, markArchiveDialog, submitExport, editIconSpec: EDIT_ICON_SPEC };
1768
+ exports.__test = { formatBytes, matchesArchivedSession, filterByTag, sortArchivedSessions, setVisibleSelection, reconcileSelection, markArchiveDialog, submitExport, submitImportFile, editIconSpec: EDIT_ICON_SPEC };
1545
1769
  exports.apply = apply;
1546
1770
  exports.inject = inject;
1547
1771
  return module.exports;
package/lib/import.js ADDED
@@ -0,0 +1,253 @@
1
+ import { unzipSync } from 'fflate';
2
+ import { normalizeMetadata } from './metadata.js';
3
+
4
+ export const IMPORT_LIMITS = Object.freeze({
5
+ maxCompressedBytes: 512 * 1024 * 1024,
6
+ maxUncompressedBytes: 256 * 1024 * 1024,
7
+ maxSessions: 2000,
8
+ maxJsonBytes: 4 * 1024 * 1024,
9
+ maxMarkdownBytes: 8 * 1024 * 1024,
10
+ });
11
+
12
+ const MANIFEST_FORMAT = 'dsh-archived-chats/export';
13
+ const SESSION_FORMAT = 'dsh-archived-chats/session';
14
+ const forbiddenKeys = new Set(['__proto__', 'constructor', 'prototype']);
15
+
16
+ function error(code, path, message) {
17
+ return { code, path, message };
18
+ }
19
+
20
+ function isObject(value) {
21
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
22
+ }
23
+
24
+ function scanKeys(value, path = '$') {
25
+ if (Array.isArray(value)) {
26
+ for (let index = 0; index < value.length; index += 1) scanKeys(value[index], `${path}[${index}]`);
27
+ return null;
28
+ }
29
+ if (!isObject(value)) return null;
30
+ for (const [key, child] of Object.entries(value)) {
31
+ if (forbiddenKeys.has(key)) return error('json-key-unsafe', `${path}.${key}`, `unsupported JSON key ${key}`);
32
+ const found = scanKeys(child, `${path}.${key}`);
33
+ if (found !== null) return found;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ function decodeUtf8(bytes, path) {
39
+ try {
40
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
41
+ } catch {
42
+ throw Object.assign(new Error(`invalid UTF-8 in ${path}`), { importError: error('utf8-invalid', path, 'entry is not valid UTF-8') });
43
+ }
44
+ }
45
+
46
+ function parseJson(bytes, path) {
47
+ let value;
48
+ try { value = JSON.parse(decodeUtf8(bytes, path)); }
49
+ catch (cause) {
50
+ if (cause?.importError) throw cause;
51
+ throw Object.assign(new Error(`invalid JSON in ${path}`), { importError: error('json-invalid', path, 'entry is not valid JSON') });
52
+ }
53
+ const unsafe = scanKeys(value);
54
+ if (unsafe !== null) throw Object.assign(new Error(unsafe.message), { importError: unsafe });
55
+ return value;
56
+ }
57
+
58
+ function safeEntryName(name) {
59
+ if (typeof name !== 'string' || name === '' || name.includes('\\') || name.includes('\u0000')) return false;
60
+ if (name.startsWith('/') || name.includes(':')) return false;
61
+ if (/^[\u0000-\u001f\u007f]/u.test(name) || /[\u0000-\u001f\u007f]/u.test(name)) return false;
62
+ if (name.endsWith('/')) return true;
63
+ const segments = name.split('/');
64
+ return segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..');
65
+ }
66
+
67
+ function indexZip(bytes) {
68
+ if (!(bytes instanceof Uint8Array)) throw Object.assign(new Error('ZIP source must be Uint8Array'), { importError: error('source-invalid', '$', 'source bytes are required') });
69
+ let unzipped;
70
+ try { unzipped = unzipSync(bytes); }
71
+ catch { throw Object.assign(new Error('ZIP cannot be read'), { importError: error('zip-invalid', '$', 'archive is not a readable ZIP') }); }
72
+ const entries = new Map();
73
+ let total = 0;
74
+ for (const [name, value] of Object.entries(unzipped)) {
75
+ if (!safeEntryName(name)) throw Object.assign(new Error(`unsafe ZIP entry ${name}`), { importError: error('path-unsafe', name, 'ZIP entry path is unsafe') });
76
+ if (name.endsWith('/')) continue;
77
+ if (entries.has(name)) throw Object.assign(new Error(`duplicate ZIP entry ${name}`), { importError: error('entry-duplicate', name, 'ZIP contains duplicate entry names') });
78
+ const bytesValue = value instanceof Uint8Array ? value : new Uint8Array(value);
79
+ total += bytesValue.byteLength;
80
+ entries.set(name, bytesValue);
81
+ }
82
+ return { entries, total };
83
+ }
84
+
85
+ function requireString(value, path, errors, { nullable = false } = {}) {
86
+ if (nullable && value === null) return;
87
+ if (typeof value !== 'string' || value === '') errors.push(error('field-invalid', path, `${path} must be a non-empty string`));
88
+ }
89
+
90
+ function requireFinite(value, path, errors, { nullable = false } = {}) {
91
+ if (nullable && value === null) return;
92
+ if (!Number.isFinite(value)) errors.push(error('field-invalid', path, `${path} must be finite`));
93
+ }
94
+
95
+ function sameJson(left, right) {
96
+ return JSON.stringify(left) === JSON.stringify(right);
97
+ }
98
+
99
+ function hasAttachment(value) {
100
+ if (Array.isArray(value)) return value.some(hasAttachment);
101
+ if (!isObject(value)) return false;
102
+ if (value.type === 'image' || value.type === 'attachment' || typeof value.attachmentId === 'string') return true;
103
+ return Object.values(value).some(hasAttachment);
104
+ }
105
+
106
+ function validateDescriptor(descriptor, path, errors) {
107
+ if (!isObject(descriptor)) {
108
+ errors.push(error('field-invalid', path, `${path} must be an object`));
109
+ return;
110
+ }
111
+ requireString(descriptor.id, `${path}.id`, errors);
112
+ requireString(descriptor.title, `${path}.title`, errors, { nullable: true });
113
+ requireFinite(descriptor.createdAt, `${path}.createdAt`, errors, { nullable: true });
114
+ requireString(descriptor.origin, `${path}.origin`, errors, { nullable: true });
115
+ requireString(descriptor.metadataUpdatedAt, `${path}.metadataUpdatedAt`, errors, { nullable: true });
116
+ if (!Array.isArray(descriptor.tags) || descriptor.tags.some((tag) => typeof tag !== 'string')) errors.push(error('metadata-invalid', `${path}.tags`, 'tags must be an array of strings'));
117
+ if (typeof descriptor.note !== 'string' && descriptor.note !== null) errors.push(error('metadata-invalid', `${path}.note`, 'note must be a string or null'));
118
+ if (!isObject(descriptor.workspace) && descriptor.workspace !== null) errors.push(error('field-invalid', `${path}.workspace`, 'workspace must be an object or null'));
119
+ if (!isObject(descriptor.storage)) errors.push(error('field-invalid', `${path}.storage`, 'storage must be an object'));
120
+ else {
121
+ if (!['ready', 'unavailable'].includes(descriptor.storage.status)) errors.push(error('field-invalid', `${path}.storage.status`, 'storage status is unsupported'));
122
+ if (descriptor.storage.status === 'ready') {
123
+ requireFinite(descriptor.storage.sizeBytes, `${path}.storage.sizeBytes`, errors);
124
+ requireFinite(descriptor.storage.fileCount, `${path}.storage.fileCount`, errors);
125
+ }
126
+ }
127
+ if (!isObject(descriptor.files)) errors.push(error('field-invalid', `${path}.files`, 'files must be an object'));
128
+ else {
129
+ requireString(descriptor.files.json, `${path}.files.json`, errors);
130
+ requireString(descriptor.files.markdown, `${path}.files.markdown`, errors);
131
+ }
132
+ }
133
+
134
+ function normalizeWarnings(item, record) {
135
+ const warnings = [];
136
+ if (hasAttachment(record.source?.events)) warnings.push('attachments-not-included');
137
+ if (item.workspace === null || item.workspace?.id === null) warnings.push('workspace-unresolved');
138
+ return warnings;
139
+ }
140
+
141
+ function importFailure(cause) {
142
+ if (cause?.importError) return { ok: false, errors: [cause.importError] };
143
+ return { ok: false, errors: [error('import-invalid', '$', String(cause?.message ?? cause))] };
144
+ }
145
+
146
+ /**
147
+ * Validate a version-one archived backup without writing to disk or DSH.
148
+ * The current implementation receives bounded bytes from the host route;
149
+ * every decompressed entry is still checked against per-entry and total caps.
150
+ */
151
+ export function inspectImport(source) {
152
+ try {
153
+ const bytes = source?.bytes;
154
+ const compressedBytes = Number.isFinite(source?.compressedBytes) ? source.compressedBytes : bytes?.byteLength;
155
+ if (!(bytes instanceof Uint8Array) || !Number.isFinite(compressedBytes)) throw Object.assign(new Error('source bytes are required'), { importError: error('source-invalid', '$', 'source bytes are required') });
156
+ if (compressedBytes > IMPORT_LIMITS.maxCompressedBytes) throw Object.assign(new Error('compressed package exceeds limit'), { importError: error('limit-exceeded', '$', 'compressed package exceeds limit') });
157
+
158
+ const { entries, total } = indexZip(bytes);
159
+ if (total > IMPORT_LIMITS.maxUncompressedBytes) throw Object.assign(new Error('uncompressed package exceeds limit'), { importError: error('limit-exceeded', '$', 'uncompressed package exceeds limit') });
160
+ const manifestBytes = entries.get('manifest.json');
161
+ if (manifestBytes === undefined) throw Object.assign(new Error('manifest.json is missing'), { importError: error('manifest-missing', 'manifest.json', 'root manifest.json is required') });
162
+ const manifest = parseJson(manifestBytes, 'manifest.json');
163
+ if (manifest?.format !== MANIFEST_FORMAT || manifest?.version !== 1) throw Object.assign(new Error('unsupported manifest format'), { importError: error('format-unsupported', 'manifest.json', 'only export format version 1 is supported') });
164
+ if (!Number.isInteger(manifest.sessionCount) || manifest.sessionCount < 1 || manifest.sessionCount > IMPORT_LIMITS.maxSessions || !Array.isArray(manifest.sessions) || manifest.sessions.length !== manifest.sessionCount) {
165
+ throw Object.assign(new Error('invalid manifest session count'), { importError: error('session-count-invalid', 'manifest.sessionCount', 'sessionCount must match 1..2000 manifest sessions') });
166
+ }
167
+ if (manifest.attachmentsIncluded !== false) throw Object.assign(new Error('attachment bytes are not accepted'), { importError: error('attachments-unsupported', 'manifest.attachmentsIncluded', 'version-one packages must not include attachment bytes') });
168
+
169
+ const errors = [];
170
+ const ids = new Set();
171
+ const referenced = new Set(['manifest.json']);
172
+ const items = [];
173
+ for (let index = 0; index < manifest.sessions.length; index += 1) {
174
+ const descriptor = manifest.sessions[index];
175
+ const path = `manifest.sessions[${index}]`;
176
+ validateDescriptor(descriptor, path, errors);
177
+ if (!isObject(descriptor) || typeof descriptor.id !== 'string') continue;
178
+ if (ids.has(descriptor.id)) errors.push(error('session-duplicate', `${path}.id`, 'session IDs must be unique'));
179
+ ids.add(descriptor.id);
180
+ const jsonName = descriptor.files?.json;
181
+ const markdownName = descriptor.files?.markdown;
182
+ if (typeof jsonName !== 'string' || typeof markdownName !== 'string') continue;
183
+ if (!safeEntryName(jsonName) || !safeEntryName(markdownName) || !jsonName.startsWith('sessions/') || !markdownName.startsWith('sessions/')) {
184
+ errors.push(error('path-unsafe', path, 'manifest file paths must be safe sessions/ paths'));
185
+ continue;
186
+ }
187
+ if (referenced.has(jsonName) || referenced.has(markdownName)) errors.push(error('entry-duplicate', path, 'manifest references duplicate file paths'));
188
+ referenced.add(jsonName); referenced.add(markdownName);
189
+ const jsonBytes = entries.get(jsonName);
190
+ const markdownBytes = entries.get(markdownName);
191
+ if (jsonBytes === undefined) { errors.push(error('entry-missing', jsonName, 'session JSON entry is missing')); continue; }
192
+ if (markdownBytes === undefined) { errors.push(error('entry-missing', markdownName, 'transcript entry is missing')); continue; }
193
+ if (jsonBytes.byteLength > IMPORT_LIMITS.maxJsonBytes) errors.push(error('limit-exceeded', jsonName, 'session JSON exceeds limit'));
194
+ if (markdownBytes.byteLength > IMPORT_LIMITS.maxMarkdownBytes) errors.push(error('limit-exceeded', markdownName, 'transcript exceeds limit'));
195
+ let record;
196
+ try { record = parseJson(jsonBytes, jsonName); }
197
+ catch (cause) { errors.push(cause.importError ?? error('json-invalid', jsonName, 'session JSON is invalid')); continue; }
198
+ if (record?.format !== SESSION_FORMAT || record?.version !== 1 || !isObject(record.archive) || record.archive.id !== descriptor.id) {
199
+ errors.push(error('session-mismatch', jsonName, 'session JSON format, version, or archive.id does not match manifest'));
200
+ continue;
201
+ }
202
+ if (!sameJson(record.archive, descriptor)) errors.push(error('session-mismatch', jsonName, 'manifest descriptor does not match session archive descriptor'));
203
+ try { normalizeMetadata({ tags: descriptor.tags, note: descriptor.note ?? '' }); }
204
+ catch { errors.push(error('metadata-invalid', `${path}.tags`, 'tags or note exceed metadata limits')); }
205
+ items.push({
206
+ id: descriptor.id,
207
+ title: descriptor.title,
208
+ workspace: descriptor.workspace,
209
+ createdAt: descriptor.createdAt,
210
+ origin: descriptor.origin,
211
+ metadataUpdatedAt: descriptor.metadataUpdatedAt,
212
+ tags: descriptor.tags,
213
+ note: descriptor.note ?? '',
214
+ storage: descriptor.storage,
215
+ hasAttachmentReferences: hasAttachment(record.source?.events),
216
+ warnings: normalizeWarnings(descriptor, record),
217
+ record,
218
+ jsonBytes,
219
+ transcriptSizeBytes: markdownBytes.byteLength,
220
+ });
221
+ }
222
+ for (const name of entries.keys()) if (!referenced.has(name)) errors.push(error('entry-unreferenced', name, 'ZIP contains an unreferenced file'));
223
+ if (errors.length > 0) return { ok: false, errors };
224
+ return {
225
+ ok: true,
226
+ plan: {
227
+ manifest,
228
+ items,
229
+ warnings: items.flatMap((item) => item.warnings.map((reason) => ({ id: item.id, reason }))),
230
+ totalBytes: total,
231
+ },
232
+ };
233
+ } catch (cause) {
234
+ return importFailure(cause);
235
+ }
236
+ }
237
+
238
+ export function selectImportItems(plan, selectedIds, conflicts = new Set()) {
239
+ const selected = Array.isArray(selectedIds) ? selectedIds : [];
240
+ const byId = new Map((plan?.items ?? []).map((item) => [item.id, item]));
241
+ const records = [];
242
+ const skipped = [];
243
+ const seen = new Set();
244
+ for (const id of selected) {
245
+ if (typeof id !== 'string' || seen.has(id)) continue;
246
+ seen.add(id);
247
+ const item = byId.get(id);
248
+ if (item === undefined) { skipped.push({ id, reason: 'selection-unknown' }); continue; }
249
+ if (conflicts.has(id)) { skipped.push({ id, reason: 'id-conflict' }); continue; }
250
+ records.push(item);
251
+ }
252
+ return { records, skipped };
253
+ }
package/lib/index.js CHANGED
@@ -67,11 +67,14 @@
67
67
  * @module dsh-archived-chats
68
68
  */
69
69
  import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
70
+ import { randomUUID } from 'node:crypto';
70
71
  import { dirname, join } from 'node:path';
71
72
  import { homedir } from 'node:os';
72
73
  import { createMetadataStore, MetadataStoreError } from './metadata.js';
73
74
  import { createStatsService } from './stats.js';
74
75
  import { createExportZip, planExport } from './export.js';
76
+ import { IMPORT_LIMITS, inspectImport, selectImportItems } from './import.js';
77
+ import { createRestoreAdapter } from './restore.js';
75
78
 
76
79
  /** Cordis plugin name. */
77
80
  export const name = 'archived-chats';
@@ -86,7 +89,7 @@ const PERSISTENCE_KEYS = ['sessionPersistence'];
86
89
  const ROUTE_PREFIX = '/plugins/dsh-archived-chats';
87
90
  /** Custom header required on POSTs: cheap CSRF hardening for a loopback UI. */
88
91
  const GUARD_HEADER = 'x-dsh-archived-chats';
89
- const PLUGIN_VERSION = '0.7.0';
92
+ const PLUGIN_VERSION = '0.8.0';
90
93
 
91
94
  //#region wire helpers
92
95
  /** Read and JSON-parse a request body (empty body → {}). */
@@ -104,6 +107,77 @@ function readBody(req) {
104
107
  });
105
108
  }
106
109
 
110
+ const IMPORT_UPLOAD_LIMIT = IMPORT_LIMITS.maxCompressedBytes;
111
+
112
+ /** Read one bounded multipart ZIP part. The body is never accepted as a path. */
113
+ function readImportUpload(req) {
114
+ return new Promise((resolve, reject) => {
115
+ const chunks = [];
116
+ let bytes = 0;
117
+ let settled = false;
118
+ const fail = (message, status = 400) => {
119
+ if (settled) return;
120
+ settled = true;
121
+ reject(Object.assign(new Error(message), { status }));
122
+ };
123
+ const contentType = String(req.headers?.['content-type'] ?? '');
124
+ const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/i);
125
+ if (!boundaryMatch) { fail('multipart boundary is required'); return; }
126
+ const boundary = Buffer.from(`--${boundaryMatch[1] ?? boundaryMatch[2]}`);
127
+ req.on('data', (chunk) => {
128
+ if (settled) return;
129
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
130
+ bytes += value.length;
131
+ if (bytes > IMPORT_UPLOAD_LIMIT) { fail('import upload is too large', 413); return; }
132
+ chunks.push(value);
133
+ });
134
+ req.on('end', () => {
135
+ if (settled) return;
136
+ try {
137
+ const body = Buffer.concat(chunks);
138
+ const headerEnd = body.indexOf(Buffer.from('\r\n\r\n'));
139
+ const firstBoundary = body.indexOf(boundary);
140
+ if (firstBoundary !== 0 || headerEnd < 0) throw new Error('ZIP file field is missing');
141
+ const headers = body.subarray(boundary.length + 2, headerEnd).toString('utf8').toLowerCase();
142
+ if (!headers.includes('content-disposition:') || !headers.includes('name="file"')) throw new Error('ZIP file field is missing');
143
+ const closing = Buffer.from(`\r\n${boundary.toString()}--`);
144
+ const end = body.indexOf(closing, headerEnd + 4);
145
+ if (end < 0) throw new Error('multipart body is incomplete');
146
+ settled = true;
147
+ resolve(new Uint8Array(body.subarray(headerEnd + 4, end)));
148
+ } catch (error) { fail(String(error?.message ?? error)); }
149
+ });
150
+ req.on('error', (error) => { if (!settled) { settled = true; reject(error); } });
151
+ });
152
+ }
153
+
154
+ function createImportTokenStore({ now = () => Date.now(), ttlMs = 10 * 60 * 1000 } = {}) {
155
+ const tokens = new Map();
156
+ function cleanup() {
157
+ const time = now();
158
+ for (const [token, value] of tokens) if (value.expiresAt <= time) tokens.delete(token);
159
+ }
160
+ return {
161
+ create(plan) {
162
+ cleanup();
163
+ const token = randomUUID();
164
+ const nonce = randomUUID();
165
+ const expiresAt = now() + ttlMs;
166
+ tokens.set(token, { plan, nonce, expiresAt });
167
+ return { token, nonce, expiresAt };
168
+ },
169
+ consume(token, nonce) {
170
+ cleanup();
171
+ const value = tokens.get(token);
172
+ if (value === undefined || value.nonce !== nonce) throw Object.assign(new Error('import token is invalid or expired'), { status: 409, code: 'import-token-invalid' });
173
+ tokens.delete(token);
174
+ return value.plan;
175
+ },
176
+ remove(token) { tokens.delete(token); },
177
+ cleanup,
178
+ };
179
+ }
180
+
107
181
  const EXPORT_BODY_LIMIT = 512 * 1024;
108
182
  const EXPORT_SESSION_LIMIT = 2000;
109
183
 
@@ -558,7 +632,7 @@ async function sweepPendingDeletions(ctx, registry, persistence, statsService, m
558
632
  //#endregion
559
633
 
560
634
  //#region routes
561
- function registerRoutes(ctx, webServer, registry, persistence, titleCache, metadataStore, statsService, pendingStore, lifecycle) {
635
+ function registerRoutes(ctx, webServer, registry, persistence, titleCache, metadataStore, statsService, pendingStore, lifecycle, importTokens, restoreAdapter) {
562
636
  ctx.effect(() => webServer.register({
563
637
  kind: 'exact',
564
638
  path: `${ROUTE_PREFIX}/state`,
@@ -667,6 +741,92 @@ function registerRoutes(ctx, webServer, registry, persistence, titleCache, metad
667
741
  },
668
742
  }), 'archived-chats: export route');
669
743
 
744
+ ctx.effect(() => webServer.register({
745
+ kind: 'exact',
746
+ path: `${ROUTE_PREFIX}/import/inspect`,
747
+ handler: async (req, res) => {
748
+ if (!guard(req, res)) return;
749
+ try {
750
+ const bytes = await readImportUpload(req);
751
+ const inspected = inspectImport({ bytes, compressedBytes: bytes.byteLength });
752
+ if (!inspected.ok) {
753
+ send(res, 400, { ok: false, error: 'import-invalid', errors: inspected.errors });
754
+ return;
755
+ }
756
+ const known = new Set();
757
+ try {
758
+ for (const header of await persistence.list()) known.add(String(header.id));
759
+ } catch { /* preview can still show package validity */ }
760
+ const archived = new Set(registry.archivedSessionIds.map(String));
761
+ const workspaceIds = new Set((registry.list?.() ?? []).map((workspace) => String(workspace.id)));
762
+ const token = importTokens.create(inspected.plan);
763
+ const sessions = inspected.plan.items.map((item) => {
764
+ const conflict = known.has(item.id) || archived.has(item.id);
765
+ const warnings = [...item.warnings];
766
+ if (item.workspace?.id !== null && item.workspace?.id !== undefined && !workspaceIds.has(String(item.workspace.id))) warnings.push('workspace-unresolved');
767
+ return {
768
+ id: item.id,
769
+ title: item.title,
770
+ workspace: item.workspace,
771
+ tags: item.tags,
772
+ note: item.note,
773
+ storage: item.storage,
774
+ hasAttachmentReferences: item.hasAttachmentReferences,
775
+ warnings: [...new Set(warnings)],
776
+ conflict,
777
+ };
778
+ });
779
+ send(res, 200, {
780
+ ok: true,
781
+ token: token.token,
782
+ nonce: token.nonce,
783
+ expiresAt: token.expiresAt,
784
+ package: {
785
+ generator: inspected.plan.manifest.generator,
786
+ version: inspected.plan.manifest.version,
787
+ sessionCount: inspected.plan.manifest.sessionCount,
788
+ totalBytes: inspected.plan.totalBytes,
789
+ },
790
+ sessions,
791
+ });
792
+ } catch (error) {
793
+ send(res, error.status ?? 400, { ok: false, error: error.code ?? 'import-invalid', message: String(error?.message ?? error) });
794
+ }
795
+ },
796
+ }), 'archived-chats: import inspect route');
797
+
798
+ ctx.effect(() => webServer.register({
799
+ kind: 'exact',
800
+ path: `${ROUTE_PREFIX}/import/restore`,
801
+ handler: async (req, res) => {
802
+ if (!guard(req, res)) return;
803
+ let body;
804
+ try {
805
+ body = await readBody(req);
806
+ if (typeof body.token !== 'string' || typeof body.nonce !== 'string' || !Array.isArray(body.sessionIds)) {
807
+ send(res, 400, { ok: false, error: 'import-confirmation-invalid' });
808
+ return;
809
+ }
810
+ const plan = importTokens.consume(body.token, body.nonce);
811
+ const currentIds = new Set();
812
+ try { for (const header of await persistence.list()) currentIds.add(String(header.id)); } catch { /* registry IDs still protect archive conflicts */ }
813
+ for (const id of registry.archivedSessionIds.map(String)) currentIds.add(id);
814
+ const selected = selectImportItems(plan, body.sessionIds, currentIds);
815
+ if (selected.records.length === 0) {
816
+ send(res, 409, { ok: false, error: 'nothing-to-restore', skipped: selected.skipped });
817
+ return;
818
+ }
819
+ const transaction = await restoreAdapter.prepare(selected.records, { knownIds: currentIds });
820
+ for (const item of selected.records) await transaction.stage(item);
821
+ const result = await transaction.commit();
822
+ send(res, 200, { ok: true, restored: result.restored, skipped: selected.skipped, warnings: result.warnings });
823
+ } catch (error) {
824
+ const status = error.status ?? (error.code === 'restore-unsupported' ? 501 : 500);
825
+ send(res, status, { ok: false, error: error.code ?? 'restore-failed', message: String(error?.message ?? error) });
826
+ }
827
+ },
828
+ }), 'archived-chats: import restore route');
829
+
670
830
  ctx.effect(() => webServer.register({
671
831
  kind: 'exact',
672
832
  path: `${ROUTE_PREFIX}/metadata`,
@@ -799,6 +959,7 @@ export function apply(ctx) {
799
959
  const metadataPath = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'plugin-data', 'archived-chats', 'metadata.json');
800
960
  const metadataStore = createMetadataStore({ filePath: metadataPath });
801
961
  const pendingStore = createPendingStore();
962
+ const importTokens = createImportTokenStore();
802
963
  const lifecycle = createLifecycleQueue();
803
964
  let statsService;
804
965
  const registerWebSurface = () => {
@@ -809,7 +970,14 @@ export function apply(ctx) {
809
970
  if (webServer === undefined || registry === undefined || persistence === undefined) return;
810
971
  registered = true;
811
972
  statsService ??= createStatsService({ persistence });
812
- registerRoutes(ctx, webServer, registry, persistence, titleCache, metadataStore, statsService, pendingStore, lifecycle);
973
+ const restoreAdapter = createRestoreAdapter({
974
+ ctx,
975
+ persistence,
976
+ registry,
977
+ metadataStore,
978
+ tempRoot: join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'plugin-data', 'archived-chats', 'imports'),
979
+ });
980
+ registerRoutes(ctx, webServer, registry, persistence, titleCache, metadataStore, statsService, pendingStore, lifecycle, importTokens, restoreAdapter);
813
981
  // Boot sweep: every pending-deletion id is cold now (plugin activation
814
982
  // precedes any client resume), so complete each deferred deletion.
815
983
  void sweepPendingDeletions(ctx, registry, persistence, statsService, metadataStore, pendingStore, lifecycle)
package/lib/restore.js ADDED
@@ -0,0 +1,151 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ function unsupported(reason = 'writer-missing') {
5
+ const error = new Error('restore is unsupported by this Harness host');
6
+ error.code = 'restore-unsupported';
7
+ error.reason = reason;
8
+ return error;
9
+ }
10
+
11
+ function resolveWriter(persistence) {
12
+ if (typeof persistence?.restoreSession === 'function') return persistence.restoreSession.bind(persistence);
13
+ if (typeof persistence?.restore === 'function') return persistence.restore.bind(persistence);
14
+ if (typeof persistence?.importSession === 'function') return persistence.importSession.bind(persistence);
15
+ return null;
16
+ }
17
+
18
+ function resolveUndo(persistence, result, id) {
19
+ if (typeof result === 'function') return result;
20
+ if (typeof result?.undo === 'function') return result.undo.bind(result);
21
+ const remove = persistence.removeSession ?? persistence.deleteSession ?? persistence.remove;
22
+ if (typeof remove === 'function') return () => remove.call(persistence, id);
23
+ return async () => {};
24
+ }
25
+
26
+ function workspaceFor(registry, workspaceId) {
27
+ if (workspaceId === null || workspaceId === undefined) return null;
28
+ return registry.list?.().find((workspace) => String(workspace.id) === String(workspaceId)) ?? null;
29
+ }
30
+
31
+ function warningFor(item) {
32
+ return [{ id: item.id, reason: 'workspace-unresolved' }];
33
+ }
34
+
35
+ /**
36
+ * Build a restore adapter around explicit host writer capabilities. This module
37
+ * never constructs Harness session-log files; it only calls a writer exposed by
38
+ * the running host and brackets it with reversible registry/metadata changes.
39
+ */
40
+ export function createRestoreAdapter({ ctx, persistence, registry, metadataStore, tempRoot }) {
41
+ const writer = resolveWriter(persistence);
42
+ const capability = writer === null
43
+ ? { supported: false, reason: 'writer-missing' }
44
+ : { supported: true };
45
+
46
+ async function prepare(records, { knownIds = new Set() } = {}) {
47
+ if (!capability.supported) throw unsupported(capability.reason);
48
+ const items = Array.isArray(records) ? records : [];
49
+ const ids = items.map((item) => String(item.id));
50
+ const duplicate = ids.find((id, index) => ids.indexOf(id) !== index);
51
+ if (duplicate !== undefined) throw Object.assign(new Error(`duplicate restore id ${duplicate}`), { code: 'restore-duplicate' });
52
+ if (ids.some((id) => knownIds.has(id))) throw Object.assign(new Error('restore records contain an ID conflict'), { code: 'id-conflict' });
53
+ const root = tempRoot ?? join(process.env.DSH_HOME ?? '/tmp', 'plugin-data', 'archived-chats', 'imports');
54
+ await mkdir(root, { recursive: true, mode: 0o700 });
55
+ const staging = await mkdtemp(join(root, 'restore-'));
56
+ const originalArchiveIds = (registry.archivedSessionIds ?? registry.state?.archivedSessionIds ?? []).map(String);
57
+ const originalMetadata = await metadataStore.getMany(ids);
58
+ const staged = [];
59
+ let committed = false;
60
+ let cleaned = false;
61
+
62
+ async function cleanup() {
63
+ if (!cleaned) {
64
+ cleaned = true;
65
+ await rm(staging, { recursive: true, force: true });
66
+ }
67
+ }
68
+
69
+ const transaction = {
70
+ async stage(item) {
71
+ if (!items.some((candidate) => candidate.id === item?.id)) throw Object.assign(new Error('record was not prepared'), { code: 'restore-record-unknown' });
72
+ if (staged.some((candidate) => candidate.id === item.id)) return;
73
+ if (!item?.record?.source || item.record?.format !== 'dsh-archived-chats/session') throw Object.assign(new Error('invalid restore record'), { code: 'restore-record-invalid' });
74
+ const path = join(staging, `${staged.length}-${encodeURIComponent(item.id)}.json`);
75
+ await writeFile(path, `${JSON.stringify(item.record)}\n`, { encoding: 'utf8', mode: 0o600 });
76
+ if (typeof persistence.inspect === 'function') {
77
+ // The host inspection is a capability check only; it does not replace
78
+ // the staged record or mutate any existing session.
79
+ await Promise.resolve(persistence.inspect(item.id, { staged: item.record }));
80
+ }
81
+ staged.push(item);
82
+ },
83
+ async rollback() {
84
+ const rollbackErrors = [];
85
+ for (const undo of transaction._undos?.slice().reverse() ?? []) {
86
+ try { await undo(); } catch (error) { rollbackErrors.push(error); }
87
+ }
88
+ for (const undo of transaction._workspaceUndos?.slice().reverse() ?? []) {
89
+ try { await undo(); } catch (error) { rollbackErrors.push(error); }
90
+ }
91
+ try {
92
+ if (typeof registry.setState === 'function' && registry.state !== undefined) {
93
+ await registry.setState({ ...registry.state, archivedSessionIds: originalArchiveIds });
94
+ }
95
+ } catch (error) { rollbackErrors.push(error); }
96
+ for (const item of staged.slice().reverse()) {
97
+ try {
98
+ const previous = originalMetadata.entries?.[item.id];
99
+ if (previous === undefined) await metadataStore.remove([item.id]);
100
+ else await metadataStore.set(item.id, { tags: previous.tags, note: previous.note });
101
+ } catch (error) { rollbackErrors.push(error); }
102
+ }
103
+ try { await cleanup(); } catch (error) { rollbackErrors.push(error); }
104
+ if (rollbackErrors.length > 0) throw Object.assign(new Error('restore rollback failed'), { code: 'restore-rollback-failed', cause: rollbackErrors[0] });
105
+ },
106
+ async commit() {
107
+ if (committed) throw Object.assign(new Error('restore transaction already committed'), { code: 'restore-replayed' });
108
+ if (staged.length === 0) throw Object.assign(new Error('no staged records'), { code: 'nothing-to-restore' });
109
+ transaction._undos = [];
110
+ transaction._workspaceUndos = [];
111
+ const restored = [];
112
+ const warnings = [];
113
+ try {
114
+ for (const item of staged) {
115
+ const payload = {
116
+ id: item.id,
117
+ meta: item.record.source.meta,
118
+ events: item.record.source.events,
119
+ archive: item.record.archive,
120
+ };
121
+ const result = await writer(payload);
122
+ transaction._undos.push(resolveUndo(persistence, result, item.id));
123
+ const workspace = workspaceFor(registry, item.workspace?.id);
124
+ if (workspace === null) {
125
+ warnings.push(...warningFor(item));
126
+ } else if (typeof workspace.attachSession === 'function') {
127
+ await workspace.attachSession(item.id);
128
+ if (typeof workspace.detachSession === 'function') transaction._workspaceUndos.push(() => workspace.detachSession(item.id));
129
+ }
130
+ if (item.hasAttachmentReferences) warnings.push({ id: item.id, reason: 'attachments-not-included' });
131
+ await metadataStore.set(item.id, { tags: item.tags, note: item.note });
132
+ restored.push(item.id);
133
+ }
134
+ if (typeof registry.setState !== 'function' || registry.state === undefined) throw Object.assign(new Error('archive registry writer is unavailable'), { code: 'restore-unsupported' });
135
+ const current = registry.archivedSessionIds.map(String);
136
+ await registry.setState({ ...registry.state, archivedSessionIds: [...current, ...restored] });
137
+ committed = true;
138
+ await cleanup();
139
+ return { restored, warnings };
140
+ } catch (error) {
141
+ try { await transaction.rollback(); }
142
+ catch (rollbackError) { throw rollbackError; }
143
+ throw error;
144
+ }
145
+ },
146
+ };
147
+ return transaction;
148
+ }
149
+
150
+ return { capability, prepare };
151
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Browser client entry — the Archived Chats settings section. Renders the
3
3
  * summary strip, tag/type/project filters, per-row tag chips and storage
4
- * sizes, single/batch backup actions, and an accessible tags/note editor,
5
- * all localized in English and 中文.
4
+ * sizes, single/batch backup actions, preview-first ZIP import/restore, and
5
+ * accessible tags/note editors, all localized in English and 中文.
6
6
  */
7
7
  export declare const SETTINGS_NS: string;
8
8
  export declare function apply(ctx: unknown): void;
@@ -1,9 +1,11 @@
1
1
  /**
2
- * Host loader entry — registers the eight `/plugins/dsh-archived-chats/*`
3
- * routes (state, stats, export, metadata, unarchive, unarchive-all, delete,
4
- * delete-all), streams JSON/Markdown backup ZIPs, and wires archive insights:
2
+ * Host loader entry — registers the `/plugins/dsh-archived-chats/*` routes
3
+ * (state, stats, export, import/inspect, import/restore, metadata, unarchive,
4
+ * unarchive-all, delete, delete-all), streams JSON/Markdown backup ZIPs, and wires archive insights:
5
5
  * per-session tags/notes joined into `/state`, storage statistics from
6
- * `/stats`, and guarded metadata mutation through `/metadata`.
6
+ * `/stats`, guarded metadata mutation through `/metadata`, and preview-first
7
+ * archive restore through the import routes. Import restore is capability
8
+ * detected and returns `restore-unsupported` when the host has no writer.
7
9
  */
8
10
  export declare function apply(ctx: unknown): void;
9
11
  export declare const name: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-archived-chats",
3
- "version": "0.7.0",
4
- "description": "DeepSeek Harness 已归档会话管理页:JSON/Markdown ZIP 备份、标签、备注、存储统计、搜索、批量操作、取消归档和删除。Archived Chats management with JSON/Markdown ZIP backups, metadata, storage insights, search, bulk actions, unarchive, and delete.",
3
+ "version": "0.8.0",
4
+ "description": "DeepSeek Harness 已归档会话管理页:搜索、标签、备注、存储统计、批量取消归档/删除、JSON/Markdown ZIP 备份,以及预览后导入恢复。Archived Chats management with search, tags, notes, storage insights, bulk actions, unarchive, delete, and preview-first JSON/Markdown ZIP import and restore.",
5
5
  "license": "MIT",
6
6
  "author": "Ultronen",
7
7
  "repository": {
@@ -40,9 +40,12 @@
40
40
  "lib/index.js",
41
41
  "lib/client.js",
42
42
  "lib/export.js",
43
+ "lib/import.js",
43
44
  "lib/metadata.js",
45
+ "lib/restore.js",
44
46
  "lib/stats.js",
45
47
  "lib/types",
48
+ "assets/screenshots",
46
49
  "cordis.patch.yml"
47
50
  ],
48
51
  "dsh": {
@@ -64,9 +67,7 @@
64
67
  },
65
68
  "dependencies": {
66
69
  "@deepseek-ai/dsh-session": "^0.1.0-rc.7",
70
+ "fflate": "^0.8.3",
67
71
  "zip-stream": "^7.0.5"
68
- },
69
- "devDependencies": {
70
- "fflate": "^0.8.3"
71
72
  }
72
73
  }