@whispering233/ai-editor-server 0.0.53 → 0.0.54

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.
Files changed (40) hide show
  1. package/client-dist/assets/{index-DBshOA8W.css → index-B2op0r9o.css} +1 -1
  2. package/client-dist/assets/{index-DnBMudDZ.js → index-Bgk8dcAm.js} +172 -169
  3. package/client-dist/index.html +2 -2
  4. package/dist/backup.d.ts +37 -44
  5. package/dist/backup.d.ts.map +1 -1
  6. package/dist/backup.js +55 -201
  7. package/dist/backup.js.map +1 -1
  8. package/dist/cloud/auto-push.d.ts +3 -3
  9. package/dist/cloud/auto-push.d.ts.map +1 -1
  10. package/dist/cloud/auto-push.js +7 -6
  11. package/dist/cloud/auto-push.js.map +1 -1
  12. package/dist/cloud/state.d.ts +6 -0
  13. package/dist/cloud/state.d.ts.map +1 -1
  14. package/dist/cloud/state.js +25 -0
  15. package/dist/cloud/state.js.map +1 -1
  16. package/dist/cloud/sync.d.ts +4 -6
  17. package/dist/cloud/sync.d.ts.map +1 -1
  18. package/dist/cloud/sync.js +8 -23
  19. package/dist/cloud/sync.js.map +1 -1
  20. package/dist/decompose/llm.d.ts.map +1 -1
  21. package/dist/decompose/llm.js +2 -1
  22. package/dist/decompose/llm.js.map +1 -1
  23. package/dist/last-project.d.ts +5 -0
  24. package/dist/last-project.d.ts.map +1 -1
  25. package/dist/last-project.js +18 -0
  26. package/dist/last-project.js.map +1 -1
  27. package/dist/middleware/project.d.ts +4 -3
  28. package/dist/middleware/project.d.ts.map +1 -1
  29. package/dist/middleware/project.js +27 -3
  30. package/dist/middleware/project.js.map +1 -1
  31. package/dist/routes/cloud.d.ts.map +1 -1
  32. package/dist/routes/cloud.js +168 -11
  33. package/dist/routes/cloud.js.map +1 -1
  34. package/dist/routes/decompose.js +1 -1
  35. package/dist/routes/decompose.js.map +1 -1
  36. package/dist/routes/project.d.ts +20 -0
  37. package/dist/routes/project.d.ts.map +1 -1
  38. package/dist/routes/project.js +139 -9
  39. package/dist/routes/project.js.map +1 -1
  40. package/package.json +5 -5
@@ -13,8 +13,8 @@
13
13
  // localStorage 不可用(隐私模式等)时静默跳过,默认浅色
14
14
  }
15
15
  </script>
16
- <script type="module" crossorigin src="/assets/index-DnBMudDZ.js"></script>
17
- <link rel="stylesheet" crossorigin href="/assets/index-DBshOA8W.css">
16
+ <script type="module" crossorigin src="/assets/index-Bgk8dcAm.js"></script>
17
+ <link rel="stylesheet" crossorigin href="/assets/index-B2op0r9o.css">
18
18
  </head>
19
19
  <body>
20
20
  <div id="root"></div>
package/dist/backup.d.ts CHANGED
@@ -3,24 +3,16 @@ import type { ProjectContext } from "./middleware/project.js";
3
3
  /** 备份目录名(项目目录内 .backups/,随书籍移动自然携带) */
4
4
  export declare const BACKUPS_DIR_NAME = ".backups";
5
5
  /**
6
- * 随备份 zip 整体打包的目录(目录名已登记在 `docs/api/20-api-backup.md`):
7
- * 只一个 —— `sessions/`(会话 JSONL)。语义:白名单前缀 + 递归打包 + 参与变更判定 + 恢复时整体覆盖。
8
- * `SESSIONS_DIR_NAME` 取自 agent 包的 pi 运行时(会话目录的唯一事实来源;pi 的 session 文件落在此目录)。
9
- * (卡 12.7b:原 `references/` 随包目录已退役——参考资料正文在 `data.db` 的 `document_records` 里。)
6
+ * **遗留白名单**(2026-09 会话移出备份后只剩这一条):`sessions/` 曾是随包目录,现在是
7
+ * **纯本地目录**(不进任何 zip)。本清单只服务「接受旧包」——`isAllowedBackupEntry` /
8
+ * `validateBackupPackage` 放行 `sessions/**` 条目(存量旧包继续可导入),条目内容**一律忽略**
9
+ * (恢复/导入/云拉取都不写不删本机会话)。
10
+ * `SESSIONS_DIR_NAME` 取自 agent 包的 pi 运行时(会话目录的唯一事实来源)。
10
11
  */
11
- export declare const PACKED_DIR_NAMES: readonly string[];
12
- /** 备份包条目白名单判定(三文件 + 打包目录前缀——逐名比对天然防 zip 路径穿越;
12
+ export declare const LEGACY_PACKED_DIR_NAMES: readonly string[];
13
+ /** 备份包条目白名单判定(三文件 + 遗留目录前缀——逐名比对天然防 zip 路径穿越;
13
14
  * 目录子路径拒绝含 `..` 的条目防相对路径逃逸) */
14
15
  export declare function isAllowedBackupEntry(name: string): boolean;
15
- /** 并集合并计数(`POST /cloud/pull` 响应的 `merged`;见 `docs/design/40-cloud-sync.md` §4) */
16
- export interface MergeCounts {
17
- /** 本机独有(云端没有、基线也没有)→ 保留 */
18
- kept: number;
19
- /** 云端新增(本机没有、基线也没有)→ 写入 */
20
- written: number;
21
- /** 云端删除(本机有、基线有、云端没有)→ 删本机 */
22
- removed: number;
23
- }
24
16
  /** 备份文件信息(GET /backups 列表项与 POST /backup 响应) */
25
17
  export interface BackupFileInfo {
26
18
  fileName: string;
@@ -38,8 +30,9 @@ export interface BackupFileInfo {
38
30
  /**
39
31
  * 打包当前项目为 zip(export 同款管道):
40
32
  * wal_checkpoint(TRUNCATE) 把 WAL 合并回主文件(zip 内 data.db 为完整快照,
41
- * 无需附带 -wal/-shm)→ zipSync 打包(键序稳定:project.json → outline.json → data.db
42
- * → sessions/**——正文与参考资料在 data.db 内,不再有其他目录条目)。
33
+ * 无需附带 -wal/-shm)→ zipSync 打包(键序稳定:project.json → outline.json → data.db)。
34
+ * **条目恰好三文件**——`sessions/` 是纯本地目录,不进任何 zip(备份/导出/云端);
35
+ * 正文与参考资料在 data.db 内。
43
36
  * 三文件缺失任一 → 抛错(打开的项目三文件必然齐全,缺失即损坏,不导出半成品包)。
44
37
  */
45
38
  export declare function createBackupZip(project: ProjectContext): Uint8Array<ArrayBuffer>;
@@ -61,6 +54,18 @@ export declare function writeBackup(project: ProjectContext, opts?: {
61
54
  name?: string;
62
55
  kind?: BackupKind;
63
56
  }): BackupFileInfo;
57
+ /**
58
+ * 把一份**原始字节**的备份落进某项目目录的 `.backups/`(不重新打包、不改名、不参与保留策略):
59
+ * 云端导入路径专用(`POST /cloud/import-book`)——把刚下载的那份 zip 原样落进新书,让新机器立刻有
60
+ * 一份「最新本地备份」(本地备份链从这份开始,否则 `backupStale` 等本地语义会立刻报「还没备份」,
61
+ * 见 `docs/design/40-cloud-sync.md` §10 为什么 3)。
62
+ *
63
+ * 文件名走备份命名白名单(与 rename/restore 同一道 `assertBackupFileNameFormat`,防路径穿越;
64
+ * 非法 → 400);同名文件已存在 → 保留原有那份(不覆盖);写入走原子写。
65
+ *
66
+ * @param projectDir 项目目录(不必是已打开的项目——云端导入的书写入时不带连接)
67
+ */
68
+ export declare function writeRawBackupFile(projectDir: string, fileName: string, bytes: Uint8Array): void;
64
69
  /**
65
70
  * 升级兜底(卡 A):`.backups/` 里**有文件但没有任何一份可解析**(旧命名残留形态)→
66
71
  * 立即 `writeBackup(project, { kind: "auto" })` 生成一份新格式备份并返回 true;
@@ -111,7 +116,8 @@ export declare function listBackups(project: ProjectContext): BackupFileInfo[];
111
116
  * 备份包完整校验(自 import 校验顺序 3-7 提取,restore 与 import 共用):
112
117
  *
113
118
  * 1. zip 解析(流式 + 解压总字节预算 200MB,zip 炸弹防御;失败 400「不是有效的项目备份包」)
114
- * 2. 条目白名单(只接受 PROJECT_EXPORT_FILE_NAMES 三文件名 + sessions/ 目录条目;
119
+ * 2. 条目白名单(只接受 PROJECT_EXPORT_FILE_NAMES 三文件名 + 遗留的 sessions/ 目录条目——
120
+ * 存量旧包含 `sessions/**`,接受但**内容一律忽略**;新包不会再含它。
115
121
  * 逐名比对天然防 zip 路径穿越;**目录条目为非必需**——旧备份包无 sessions/ 仍可导入)
116
122
  * 3. 三文件齐全
117
123
  * 4. 临时目录写入 → project.json / outline.json 顶层(JSON 损坏 / 不符 → 400)
@@ -134,15 +140,12 @@ export declare function validateBackupPackage(zipData: Uint8Array): {
134
140
  projectName: string;
135
141
  };
136
142
  /**
137
- * 三文件 + `data.db-wal` 伴生文件 + 随包目录(`sessions/`)是否在 since 之后有变更
138
- *(「任一 mtime 晚于上次备份时刻」):
143
+ * 三文件 + `data.db-wal` 伴生文件是否在 since 之后有变更(「任一 mtime 晚于上次备份时刻」):
139
144
  * - 三文件:任一 mtime > since + 容差 → 有变更;**主文件缺失 → 视为有变更**(防御:不静默
140
145
  * 跳过,让备份管道报错暴露损坏)
141
146
  * - `data.db-wal`(F2):普通写事务只追加 `-wal`、主文件 mtime 不变;**wal 缺失 ≠ 变更**
142
147
  * (无未 checkpoint 的写,属正常状态,否则每次 tick 都误备份)
143
- * - `sessions/`:目录内**任一文件** mtime 命中,或**目录自身** mtime 命中
144
- * (F3:删除文件不刷新任何剩余文件的 mtime,只看文件会漏检删除);目录缺失(ENOENT)
145
- * = 无文件、不算变更;其余错误防御视为有变更
148
+ * `sessions/` **不参与**(纯本地目录:不进包 ⇒ 写会话不该触发备份,见 `docs/design/40-cloud-sync.md` §3)。
146
149
  * mtime 判定容差见 BACKUP_CHANGE_TOLERANCE_MS 注释。
147
150
  */
148
151
  export declare function hasFileChangesSince(project: ProjectContext, since: Date): boolean;
@@ -150,21 +153,21 @@ export declare function hasFileChangesSince(project: ProjectContext, since: Date
150
153
  * 「本机创作数据自 since 后有改动」判定(**云端三态专用**,与备份定时器的 `hasFileChangesSince`
151
154
  * 口径刻意不同——见 `docs/design/40-cloud-sync.md` §3):
152
155
  *
153
- * - `project.json` / `outline.json` / `sessions/`(含目录自身 mtime):
156
+ * - `project.json` / `outline.json`:
154
157
  * **严格** `mtime > since`。这些文件不会被备份/同步管道写入,容差在这里只有代价:
155
158
  * `lastSyncAt` 是**不推进的固定基准**(只在下一次同步时前移),容差会把 `[since, since+1s]`
156
159
  * 内的本机改动**永久漏判**(状态误报「已同步」,用户可能因此拉取覆盖它)。
157
160
  * - `data.db` / `data.db-wal`:保留 `BACKUP_CHANGE_TOLERANCE_MS` 容差——备份/推送管道内的
158
161
  * `wal_checkpoint` 会把 `data.db` mtime 刷到同步时刻,严格比较会自激误判「永远有改动」。
162
+ * - `sessions/` **不参与**(纯本地目录,不进包 ⇒ 纯聊天不产生「有东西待推」)。
159
163
  */
160
164
  export declare function hasLocalEditsSince(project: ProjectContext, since: Date): boolean;
161
165
  /**
162
166
  * 「本机**创作数据**自 since 后有改动」判定(**自动推送专用**,卡 7;`docs/design/40-cloud-sync.md` §5):
163
- * 与 `hasLocalEditsSince` 同一套 mtime 口径,但 `sessions/` **不参与**(会话是聊天产物,纯聊天时段
164
- * 不该单独烧一次云盘配额——聊天记录会随下一次创作变更的 zip 一起上云,zip 永远含 `sessions/`),
165
- * 并**纳入 `AGENTS.md`**(项目规则也是创作数据,改规则值得推一次)。
167
+ * 与 `hasLocalEditsSince` 同一套 mtime 口径(两者都不含 `sessions/`——会话是纯本地目录、不进包),
168
+ * 但**额外纳入 `AGENTS.md`**(项目规则也是创作数据,改规则值得推一次)。
166
169
  *
167
- * 创作数据本体 = 三文件(正文/参考资料在 `data.db` 内,卡 12.7b 后已无随包目录参与本档)。
170
+ * 创作数据本体 = 三文件(正文/参考资料在 `data.db` 内)。
168
171
  */
169
172
  export declare function hasAuthoringChangesSince(project: ProjectContext, since: Date): boolean;
170
173
  /**
@@ -174,7 +177,7 @@ export declare function hasAuthoringChangesSince(project: ProjectContext, since:
174
177
  export declare function latestParseableBackupTime(project: ProjectContext): Date | null;
175
178
  /**
176
179
  * 「本机有改动未进最新备份」(卡 B,`GET /cloud/status` 的 `local.backupStale`):
177
- * 最新一份本地备份的时间早于最新创作改动(三文件 + `sessions/`,口径同 `hasLocalEditsSince`,
180
+ * 最新一份本地备份的时间早于最新创作改动(三文件口径同 `hasLocalEditsSince`,
178
181
  * `data.db`/`-wal` 带 1s 容差)。**没有任何可解析备份 → true**
179
182
  *(那时推送会 404「没有可推送的备份」,语义上也是「还没有能代表当前的档」)。
180
183
  *
@@ -230,17 +233,15 @@ export declare function stopAutoBackup(): void;
230
233
  * (与 db 包 writeJsonAtomic 同款格式惯例)
231
234
  * - outline.json / data.db:原样字节原子写
232
235
  *
236
+ * **`sessions/` 一律不碰**(不写不删):它是纯本地目录,包内的存量 `sessions/**` 条目接受但忽略。
237
+ *
233
238
  * 注意:本函数只管文件层,不含 data.db 连接管理(重连见 overwriteProjectFiles)。
234
239
  */
235
240
  export declare function writeProjectFilesFromBackup(dir: string, entries: Record<string, Uint8Array>, opts?: {
236
241
  keepId?: string;
237
242
  name?: string;
238
243
  snapshotFileName?: string;
239
- /** 随包目录走**并集合并**(云拉取语义);缺省 = 整体覆盖(本地 restore / import 语义不变) */
240
- mergePackedDirs?: {
241
- baseEntries: readonly string[];
242
- };
243
- }): MergeCounts | undefined;
244
+ }): void;
244
245
  /**
245
246
  * 自动迁移:project.json 存在非空 `prompt` 且项目目录无 →
246
247
  * 将 prompt 内容**原样**写入 (原子写,同款),一次性——
@@ -279,10 +280,7 @@ export declare function migratePromptToAgents(project: ProjectContext): void;
279
280
  export declare function overwriteProjectFiles(project: ProjectContext, entries: Record<string, Uint8Array>, opts?: {
280
281
  name?: string;
281
282
  snapshotFileName?: string;
282
- mergePackedDirs?: {
283
- baseEntries: readonly string[];
284
- };
285
- }): MergeCounts | undefined;
283
+ }): void;
286
284
  /**
287
285
  * 为**未打开的书**生成覆盖前快照(import 覆盖书架其他书场景,「覆盖前自动快照」):
288
286
  * 临时打开 data.db 连接走备份管道(wal_checkpoint 保证快照完整性),完成后关闭连接。
@@ -308,12 +306,7 @@ export declare function snapshotBookDir(dir: string): BackupFileInfo;
308
306
  *
309
307
  * @throws HttpError 400(文件名非法/坏包)、404(备份不存在)、409 SCHEMA_VERSION_MISMATCH
310
308
  */
311
- export declare function restoreBackup(project: ProjectContext, fileName: string, options?: {
312
- mergePackedDirs?: {
313
- baseEntries: readonly string[];
314
- };
315
- }): {
309
+ export declare function restoreBackup(project: ProjectContext, fileName: string): {
316
310
  snapshot: Pick<BackupFileInfo, "fileName" | "createdAt">;
317
- merged?: MergeCounts;
318
311
  };
319
312
  //# sourceMappingURL=backup.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"backup.d.ts","sourceRoot":"","sources":["../src/backup.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"backup.d.ts","sourceRoot":"","sources":["../src/backup.ts"],"names":[],"mappings":"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"}
package/dist/backup.js CHANGED
@@ -33,18 +33,19 @@ import { HttpError } from "./middleware/error.js";
33
33
  /** 备份目录名(项目目录内 .backups/,随书籍移动自然携带) */
34
34
  export const BACKUPS_DIR_NAME = ".backups";
35
35
  /**
36
- * 随备份 zip 整体打包的目录(目录名已登记在 `docs/api/20-api-backup.md`):
37
- * 只一个 —— `sessions/`(会话 JSONL)。语义:白名单前缀 + 递归打包 + 参与变更判定 + 恢复时整体覆盖。
38
- * `SESSIONS_DIR_NAME` 取自 agent 包的 pi 运行时(会话目录的唯一事实来源;pi 的 session 文件落在此目录)。
39
- * (卡 12.7b:原 `references/` 随包目录已退役——参考资料正文在 `data.db` 的 `document_records` 里。)
36
+ * **遗留白名单**(2026-09 会话移出备份后只剩这一条):`sessions/` 曾是随包目录,现在是
37
+ * **纯本地目录**(不进任何 zip)。本清单只服务「接受旧包」——`isAllowedBackupEntry` /
38
+ * `validateBackupPackage` 放行 `sessions/**` 条目(存量旧包继续可导入),条目内容**一律忽略**
39
+ * (恢复/导入/云拉取都不写不删本机会话)。
40
+ * `SESSIONS_DIR_NAME` 取自 agent 包的 pi 运行时(会话目录的唯一事实来源)。
40
41
  */
41
- export const PACKED_DIR_NAMES = [SESSIONS_DIR_NAME];
42
- /** 备份包条目白名单判定(三文件 + 打包目录前缀——逐名比对天然防 zip 路径穿越;
42
+ export const LEGACY_PACKED_DIR_NAMES = [SESSIONS_DIR_NAME];
43
+ /** 备份包条目白名单判定(三文件 + 遗留目录前缀——逐名比对天然防 zip 路径穿越;
43
44
  * 目录子路径拒绝含 `..` 的条目防相对路径逃逸) */
44
45
  export function isAllowedBackupEntry(name) {
45
46
  if (PROJECT_EXPORT_FILE_NAMES.includes(name))
46
47
  return true;
47
- return PACKED_DIR_NAMES.some((d) => name.startsWith(`${d}/`)) && !name.split("/").includes("..");
48
+ return LEGACY_PACKED_DIR_NAMES.some((d) => name.startsWith(`${d}/`)) && !name.split("/").includes("..");
48
49
  }
49
50
  /**
50
51
  * 备份标签(名称段)不得等于 WebDAV 应用密码:标签会进 `.backups/` 文件名并随备份上传到云盘,
@@ -58,101 +59,6 @@ function assertNotCredential(label) {
58
59
  return;
59
60
  throw new HttpError(400, "VALIDATION_ERROR", "备份名称不能与 WebDAV 应用密码相同(它会写进备份文件名并上传到云盘)");
60
61
  }
61
- /** 项目目录内某子目录的全部文件(递归,含 .trash/)相对路径(`/` 分隔);目录缺失 → [] */
62
- function listDirFiles(root, dirName) {
63
- const base = join(root, dirName);
64
- if (!existsSync(base))
65
- return [];
66
- const out = [];
67
- const walk = (dir, prefix) => {
68
- for (const e of readdirSync(dir, { withFileTypes: true })) {
69
- const rel = prefix === "" ? e.name : `${prefix}/${e.name}`;
70
- const full = join(dir, e.name);
71
- if (e.isDirectory())
72
- walk(full, rel);
73
- else
74
- out.push(rel);
75
- }
76
- };
77
- walk(base, "");
78
- return out;
79
- }
80
- /**
81
- * 用备份条目**整体覆盖**项目目录内的打包目录(恢复/导入语义:目录以备份内容为准,
82
- * 本地残留不混入)。目录不存在则创建;`..` 条目跳过(白名单已校验,双保险)。
83
- */
84
- function overwriteDirFromEntries(dir, dirName, entries) {
85
- const baseDir = join(dir, dirName);
86
- if (existsSync(baseDir))
87
- rmSync(baseDir, { recursive: true, force: true });
88
- for (const key of Object.keys(entries)) {
89
- if (!key.startsWith(`${dirName}/`))
90
- continue;
91
- const rel = key.slice(dirName.length + 1);
92
- if (rel === "" || rel.split("/").includes(".."))
93
- continue;
94
- const target = join(baseDir, ...rel.split("/"));
95
- mkdirSync(dirname(target), { recursive: true });
96
- writeFileAtomic(target, entries[key]);
97
- }
98
- }
99
- /**
100
- * 用备份条目与本机现有文件做**并集合并**(云拉取语义;本地 restore 不走这里):
101
- * 逐文件三方比较(本机 / 云端那份 / 基线 `baseEntries`)——
102
- *
103
- * | 本机 | 云端 | 基线 | 动作 |
104
- * | 有 | 有 | — | 写云端那份(同名 = 同一资产) |
105
- * | 有 | 无 | ✔ | 删本机(云端删除传播) |
106
- * | 有 | 无 | ✘ | 保留本机(本机新增) |
107
- * | 无 | 有 | ✔ | **不写回**(本机删除优先,不被复活) |
108
- * | 无 | 有 | ✘ | 写进来(云端新增) |
109
- *
110
- * 基线由 `cloud.json` 的 `books[<id>].baseEntries`(= 最近一次推送/拉取包的条目)提供。
111
- */
112
- function mergeDirFromEntries(dir, dirName, entries, baseEntries) {
113
- const baseDir = join(dir, dirName);
114
- const rel = (key) => key.slice(dirName.length + 1);
115
- const localSet = new Set(listDirFiles(dir, dirName));
116
- const cloudKeys = Object.keys(entries).filter((k) => k.startsWith(`${dirName}/`) && rel(k) !== "");
117
- const cloudSet = new Set(cloudKeys.map(rel));
118
- const baseSet = new Set(baseEntries.filter((e) => e.startsWith(`${dirName}/`) && rel(e) !== "").map(rel));
119
- const counts = { kept: 0, written: 0, removed: 0 };
120
- const safeSegments = (path) => {
121
- const segments = path.split("/");
122
- return segments.includes("..") ? null : segments; // 白名单已校验,双保险
123
- };
124
- // 本机侧:云端没有的份,按基线判「云端删了它」还是「本机新增」
125
- for (const path of localSet) {
126
- if (cloudSet.has(path))
127
- continue; // 两边都有 → 由云端侧写云端那份
128
- const segments = safeSegments(path);
129
- if (segments === null)
130
- continue;
131
- if (baseSet.has(path)) {
132
- rmSync(join(baseDir, ...segments), { force: true });
133
- counts.removed += 1;
134
- }
135
- else {
136
- counts.kept += 1;
137
- }
138
- }
139
- // 云端侧:本机没有的份,按基线判「本机删了它」(不复活)还是「云端新增」
140
- for (const key of cloudKeys) {
141
- const path = rel(key);
142
- const segments = safeSegments(path);
143
- if (segments === null)
144
- continue;
145
- const isLocalNew = !localSet.has(path);
146
- if (isLocalNew && baseSet.has(path))
147
- continue; // 本机删除优先
148
- const target = join(baseDir, ...segments);
149
- mkdirSync(dirname(target), { recursive: true });
150
- writeFileAtomic(target, entries[key]);
151
- if (isLocalNew)
152
- counts.written += 1;
153
- }
154
- return counts;
155
- }
156
62
  /** 解压总字节预算(200MB,zip 炸弹防御——与 import 同款,restore 复用) */
157
63
  const MAX_UNZIP_BUDGET = 200 * 1024 * 1024;
158
64
  /**
@@ -182,8 +88,9 @@ function assertBackupFileNameFormat(fileName) {
182
88
  /**
183
89
  * 打包当前项目为 zip(export 同款管道):
184
90
  * wal_checkpoint(TRUNCATE) 把 WAL 合并回主文件(zip 内 data.db 为完整快照,
185
- * 无需附带 -wal/-shm)→ zipSync 打包(键序稳定:project.json → outline.json → data.db
186
- * → sessions/**——正文与参考资料在 data.db 内,不再有其他目录条目)。
91
+ * 无需附带 -wal/-shm)→ zipSync 打包(键序稳定:project.json → outline.json → data.db)。
92
+ * **条目恰好三文件**——`sessions/` 是纯本地目录,不进任何 zip(备份/导出/云端);
93
+ * 正文与参考资料在 data.db 内。
187
94
  * 三文件缺失任一 → 抛错(打开的项目三文件必然齐全,缺失即损坏,不导出半成品包)。
188
95
  */
189
96
  export function createBackupZip(project) {
@@ -194,12 +101,6 @@ export function createBackupZip(project) {
194
101
  [OUTLINE_FILE_NAME]: readFileSync(join(dir, OUTLINE_FILE_NAME)),
195
102
  [DATA_DB_FILE_NAME]: readFileSync(join(dir, DATA_DB_FILE_NAME)),
196
103
  };
197
- // 打包目录随包(存在则递归打包;不存在跳过——旧项目无目录不报错)
198
- for (const dirName of PACKED_DIR_NAMES) {
199
- for (const rel of listDirFiles(dir, dirName)) {
200
- zipEntries[`${dirName}/${rel}`] = readFileSync(join(dir, dirName, rel));
201
- }
202
- }
203
104
  return zipSync(zipEntries, { level: 6 });
204
105
  }
205
106
  /**
@@ -264,6 +165,25 @@ export function writeBackup(project, opts) {
264
165
  stats,
265
166
  };
266
167
  }
168
+ /**
169
+ * 把一份**原始字节**的备份落进某项目目录的 `.backups/`(不重新打包、不改名、不参与保留策略):
170
+ * 云端导入路径专用(`POST /cloud/import-book`)——把刚下载的那份 zip 原样落进新书,让新机器立刻有
171
+ * 一份「最新本地备份」(本地备份链从这份开始,否则 `backupStale` 等本地语义会立刻报「还没备份」,
172
+ * 见 `docs/design/40-cloud-sync.md` §10 为什么 3)。
173
+ *
174
+ * 文件名走备份命名白名单(与 rename/restore 同一道 `assertBackupFileNameFormat`,防路径穿越;
175
+ * 非法 → 400);同名文件已存在 → 保留原有那份(不覆盖);写入走原子写。
176
+ *
177
+ * @param projectDir 项目目录(不必是已打开的项目——云端导入的书写入时不带连接)
178
+ */
179
+ export function writeRawBackupFile(projectDir, fileName, bytes) {
180
+ assertBackupFileNameFormat(fileName);
181
+ const backupsDir = join(projectDir, BACKUPS_DIR_NAME);
182
+ mkdirSync(backupsDir, { recursive: true });
183
+ const path = join(backupsDir, fileName);
184
+ if (!existsSync(path))
185
+ writeFileAtomic(path, bytes);
186
+ }
267
187
  /**
268
188
  * 升级兜底(卡 A):`.backups/` 里**有文件但没有任何一份可解析**(旧命名残留形态)→
269
189
  * 立即 `writeBackup(project, { kind: "auto" })` 生成一份新格式备份并返回 true;
@@ -484,7 +404,7 @@ function unzipWithBudget(zipData, budget) {
484
404
  const unzipper = new Unzip((file) => {
485
405
  names.push(file.name);
486
406
  if (!isAllowedBackupEntry(file.name))
487
- return; // 未知条目不解压(白名单 = 三文件 + sessions/)
407
+ return; // 未知条目不解压(白名单 = 三文件 + 遗留 sessions/)
488
408
  const chunks = [];
489
409
  let size = 0;
490
410
  file.ondata = (err, chunk, final) => {
@@ -531,7 +451,8 @@ function isValidOutlineFile(parsed) {
531
451
  * 备份包完整校验(自 import 校验顺序 3-7 提取,restore 与 import 共用):
532
452
  *
533
453
  * 1. zip 解析(流式 + 解压总字节预算 200MB,zip 炸弹防御;失败 400「不是有效的项目备份包」)
534
- * 2. 条目白名单(只接受 PROJECT_EXPORT_FILE_NAMES 三文件名 + sessions/ 目录条目;
454
+ * 2. 条目白名单(只接受 PROJECT_EXPORT_FILE_NAMES 三文件名 + 遗留的 sessions/ 目录条目——
455
+ * 存量旧包含 `sessions/**`,接受但**内容一律忽略**;新包不会再含它。
535
456
  * 逐名比对天然防 zip 路径穿越;**目录条目为非必需**——旧备份包无 sessions/ 仍可导入)
536
457
  * 3. 三文件齐全
537
458
  * 4. 临时目录写入 → project.json / outline.json 顶层(JSON 损坏 / 不符 → 400)
@@ -548,7 +469,7 @@ function isValidOutlineFile(parsed) {
548
469
  * @throws HttpError 400(坏包/缺文件/未知条目/不符)/ 409 SCHEMA_VERSION_MISMATCH
549
470
  */
550
471
  export function validateBackupPackage(zipData) {
551
- // 1/2. 解压 + 白名单(严格拒绝):三数据文件名 + sessions/ 前缀
472
+ // 1/2. 解压 + 白名单(严格拒绝):三数据文件名 + 遗留的 sessions/ 前缀
552
473
  let entries;
553
474
  let entryNames;
554
475
  try {
@@ -563,7 +484,7 @@ export function validateBackupPackage(zipData) {
563
484
  }
564
485
  const unknown = entryNames.filter((k) => !isAllowedBackupEntry(k));
565
486
  if (unknown.length > 0) {
566
- throw new HttpError(400, "VALIDATION_ERROR", `备份包含未知条目: ${unknown.join(", ")}(只接受 ${PROJECT_EXPORT_FILE_NAMES.join("/")} 与 ${PACKED_DIR_NAMES.map((d) => `${d}/`).join("/")})`);
487
+ throw new HttpError(400, "VALIDATION_ERROR", `备份包含未知条目: ${unknown.join(", ")}(只接受 ${PROJECT_EXPORT_FILE_NAMES.join("/")};存量旧包另可含 ${LEGACY_PACKED_DIR_NAMES.map((d) => `${d}/`).join("/")},其内容被忽略)`);
567
488
  }
568
489
  const missing = PROJECT_EXPORT_FILE_NAMES.filter((f) => !(f in entries));
569
490
  if (missing.length > 0) {
@@ -662,15 +583,12 @@ function latestBackupTime(backupsDir) {
662
583
  return latest;
663
584
  }
664
585
  /**
665
- * 三文件 + `data.db-wal` 伴生文件 + 随包目录(`sessions/`)是否在 since 之后有变更
666
- *(「任一 mtime 晚于上次备份时刻」):
586
+ * 三文件 + `data.db-wal` 伴生文件是否在 since 之后有变更(「任一 mtime 晚于上次备份时刻」):
667
587
  * - 三文件:任一 mtime > since + 容差 → 有变更;**主文件缺失 → 视为有变更**(防御:不静默
668
588
  * 跳过,让备份管道报错暴露损坏)
669
589
  * - `data.db-wal`(F2):普通写事务只追加 `-wal`、主文件 mtime 不变;**wal 缺失 ≠ 变更**
670
590
  * (无未 checkpoint 的写,属正常状态,否则每次 tick 都误备份)
671
- * - `sessions/`:目录内**任一文件** mtime 命中,或**目录自身** mtime 命中
672
- * (F3:删除文件不刷新任何剩余文件的 mtime,只看文件会漏检删除);目录缺失(ENOENT)
673
- * = 无文件、不算变更;其余错误防御视为有变更
591
+ * `sessions/` **不参与**(纯本地目录:不进包 ⇒ 写会话不该触发备份,见 `docs/design/40-cloud-sync.md` §3)。
674
592
  * mtime 判定容差见 BACKUP_CHANGE_TOLERANCE_MS 注释。
675
593
  */
676
594
  export function hasFileChangesSince(project, since) {
@@ -696,36 +614,13 @@ export function hasFileChangesSince(project, since) {
696
614
  if (err.code !== "ENOENT")
697
615
  return true;
698
616
  }
699
- // 随包目录(sessions/):目录自身或其内任一文件 mtime 晚于 limit → 有变更——
700
- // 新增聊天会话、**删除文件**同样触发自动备份;
701
- // 遍历竞态(读取中删除)→ 防御视为无变更(下一 tick 重检)
702
- try {
703
- for (const dirName of PACKED_DIR_NAMES) {
704
- try {
705
- if (statSync(join(project.root, dirName)).mtimeMs > limit)
706
- return true;
707
- }
708
- catch (err) {
709
- if (err.code !== "ENOENT")
710
- return true;
711
- }
712
- for (const rel of listDirFiles(project.root, dirName)) {
713
- if (statSync(join(project.root, dirName, rel)).mtimeMs > limit)
714
- return true;
715
- }
716
- }
717
- }
718
- catch {
719
- // 忽略(下一 tick 重检)
720
- }
617
+ // sessions/ 不参与变更判定(纯本地目录)
721
618
  return false;
722
619
  }
723
620
  /**
724
621
  * mtime 变更判定内核(`hasLocalEditsSince` 与 `hasAuthoringChangesSince` 共用,避免两份漂移):
725
622
  * 三文件(`data.db`/`-wal` 带 `BACKUP_CHANGE_TOLERANCE_MS` 容差,其余**严格** `>`)→ 额外单文件
726
- * (`scope.extraFiles`,严格;ENOENT = 该可选文件不存在,不算变更)→ `scope.dirs` 各目录
727
- * (**目录自身**与其内任一文件 mtime 命中即算「有改动」——删除文件不刷新剩余文件 mtime,
728
- * 只看文件会漏检删除;目录缺失 = 无文件,不算变更)。
623
+ * (`scope.extraFiles`,严格;ENOENT = 该可选文件不存在,不算变更)。
729
624
  *
730
625
  * 容差只给 `data.db` 与 `-wal` 的原因:备份/推送管道内的 `wal_checkpoint` 会把其 mtime 刷到
731
626
  * 同步时刻,严格比较会自激误判「永远有改动」;其余文件不会被管道写入,给容差反而会让
@@ -769,48 +664,29 @@ function hasChangesSince(project, since, scope) {
769
664
  return true;
770
665
  }
771
666
  }
772
- try {
773
- for (const dirName of scope.dirs ?? []) {
774
- try {
775
- if (statSync(join(project.root, dirName)).mtimeMs > strictLimit)
776
- return true;
777
- }
778
- catch (err) {
779
- if (err.code !== "ENOENT")
780
- return true;
781
- }
782
- for (const rel of listDirFiles(project.root, dirName)) {
783
- if (statSync(join(project.root, dirName, rel)).mtimeMs > strictLimit)
784
- return true;
785
- }
786
- }
787
- }
788
- catch {
789
- // 遍历竞态(读取中删除):下一次状态检查重检
790
- }
791
667
  return false;
792
668
  }
793
669
  /**
794
670
  * 「本机创作数据自 since 后有改动」判定(**云端三态专用**,与备份定时器的 `hasFileChangesSince`
795
671
  * 口径刻意不同——见 `docs/design/40-cloud-sync.md` §3):
796
672
  *
797
- * - `project.json` / `outline.json` / `sessions/`(含目录自身 mtime):
673
+ * - `project.json` / `outline.json`:
798
674
  * **严格** `mtime > since`。这些文件不会被备份/同步管道写入,容差在这里只有代价:
799
675
  * `lastSyncAt` 是**不推进的固定基准**(只在下一次同步时前移),容差会把 `[since, since+1s]`
800
676
  * 内的本机改动**永久漏判**(状态误报「已同步」,用户可能因此拉取覆盖它)。
801
677
  * - `data.db` / `data.db-wal`:保留 `BACKUP_CHANGE_TOLERANCE_MS` 容差——备份/推送管道内的
802
678
  * `wal_checkpoint` 会把 `data.db` mtime 刷到同步时刻,严格比较会自激误判「永远有改动」。
679
+ * - `sessions/` **不参与**(纯本地目录,不进包 ⇒ 纯聊天不产生「有东西待推」)。
803
680
  */
804
681
  export function hasLocalEditsSince(project, since) {
805
- return hasChangesSince(project, since, { dirs: PACKED_DIR_NAMES });
682
+ return hasChangesSince(project, since, {});
806
683
  }
807
684
  /**
808
685
  * 「本机**创作数据**自 since 后有改动」判定(**自动推送专用**,卡 7;`docs/design/40-cloud-sync.md` §5):
809
- * 与 `hasLocalEditsSince` 同一套 mtime 口径,但 `sessions/` **不参与**(会话是聊天产物,纯聊天时段
810
- * 不该单独烧一次云盘配额——聊天记录会随下一次创作变更的 zip 一起上云,zip 永远含 `sessions/`),
811
- * 并**纳入 `AGENTS.md`**(项目规则也是创作数据,改规则值得推一次)。
686
+ * 与 `hasLocalEditsSince` 同一套 mtime 口径(两者都不含 `sessions/`——会话是纯本地目录、不进包),
687
+ * 但**额外纳入 `AGENTS.md`**(项目规则也是创作数据,改规则值得推一次)。
812
688
  *
813
- * 创作数据本体 = 三文件(正文/参考资料在 `data.db` 内,卡 12.7b 后已无随包目录参与本档)。
689
+ * 创作数据本体 = 三文件(正文/参考资料在 `data.db` 内)。
814
690
  */
815
691
  export function hasAuthoringChangesSince(project, since) {
816
692
  return hasChangesSince(project, since, { extraFiles: [AGENTS_FILE_NAME] });
@@ -824,7 +700,7 @@ export function latestParseableBackupTime(project) {
824
700
  }
825
701
  /**
826
702
  * 「本机有改动未进最新备份」(卡 B,`GET /cloud/status` 的 `local.backupStale`):
827
- * 最新一份本地备份的时间早于最新创作改动(三文件 + `sessions/`,口径同 `hasLocalEditsSince`,
703
+ * 最新一份本地备份的时间早于最新创作改动(三文件口径同 `hasLocalEditsSince`,
828
704
  * `data.db`/`-wal` 带 1s 容差)。**没有任何可解析备份 → true**
829
705
  *(那时推送会 404「没有可推送的备份」,语义上也是「还没有能代表当前的档」)。
830
706
  *
@@ -929,14 +805,15 @@ function scheduleNext(project) {
929
805
  * (与 db 包 writeJsonAtomic 同款格式惯例)
930
806
  * - outline.json / data.db:原样字节原子写
931
807
  *
808
+ * **`sessions/` 一律不碰**(不写不删):它是纯本地目录,包内的存量 `sessions/**` 条目接受但忽略。
809
+ *
932
810
  * 注意:本函数只管文件层,不含 data.db 连接管理(重连见 overwriteProjectFiles)。
933
811
  */
934
812
  export function writeProjectFilesFromBackup(dir, entries, opts = {}) {
935
- // 替换顺序:JSON 两文件在前,data.db 最后,sessions/ 收尾——db 替换失败时其余已替换
813
+ // 替换顺序:JSON 两文件在前,data.db 最后——db 替换失败时其余已替换
936
814
  // (校验已通过,文件内容本身有效,部分替换可经重新恢复修复);replaced 清单供失败日志使用(P1-2)
937
815
  const targetNames = [PROJECT_FILE_NAME, OUTLINE_FILE_NAME, DATA_DB_FILE_NAME];
938
816
  const replaced = [];
939
- let merged;
940
817
  try {
941
818
  const parsedProject = JSON.parse(new TextDecoder().decode(entries[PROJECT_FILE_NAME]));
942
819
  const nextProject = {
@@ -950,21 +827,6 @@ export function writeProjectFilesFromBackup(dir, entries, opts = {}) {
950
827
  replaced.push(OUTLINE_FILE_NAME);
951
828
  writeFileAtomic(join(dir, DATA_DB_FILE_NAME), entries[DATA_DB_FILE_NAME]);
952
829
  replaced.push(DATA_DB_FILE_NAME);
953
- // 随包目录(sessions/ 会话 JSONL):
954
- // - 默认「整体覆盖」——恢复/导入是「整体还原」语义,目录以备份内容为准,本地残留不混入
955
- // - `mergePackedDirs`(云拉取)= 与基线做三方比较的**并集合并**(删除优先,本机新增保留)
956
- for (const dirName of PACKED_DIR_NAMES) {
957
- if (opts.mergePackedDirs === undefined) {
958
- overwriteDirFromEntries(dir, dirName, entries);
959
- }
960
- else {
961
- const counts = mergeDirFromEntries(dir, dirName, entries, opts.mergePackedDirs.baseEntries);
962
- merged = merged ?? { kept: 0, written: 0, removed: 0 };
963
- merged.kept += counts.kept;
964
- merged.written += counts.written;
965
- merged.removed += counts.removed;
966
- }
967
- }
968
830
  }
969
831
  catch (err) {
970
832
  // P1-2:失败路径日志(对齐「记日志暴露部分替换」承诺)——已替换/未替换文件清单 + 覆盖前快照名
@@ -972,7 +834,6 @@ export function writeProjectFilesFromBackup(dir, entries, opts = {}) {
972
834
  console.error(`[backup] 覆盖文件替换失败(已替换: ${replaced.join(", ") || "无"};未替换: ${notReplaced.join(", ") || "无"}${opts.snapshotFileName !== undefined ? `;覆盖前快照: ${opts.snapshotFileName}` : ""})`, err);
973
835
  throw err;
974
836
  }
975
- return merged;
976
837
  }
977
838
  /**
978
839
  * 自动迁移:project.json 存在非空 `prompt` 且项目目录无 →
@@ -1025,7 +886,6 @@ export function migratePromptToAgents(project) {
1025
886
  export function overwriteProjectFiles(project, entries, opts = {}) {
1026
887
  const dbPath = join(project.root, DATA_DB_FILE_NAME);
1027
888
  closeDatabase(project.db); // 释放当前连接(替换 data.db 前必须;替换失败恢复见 catch)
1028
- let merged;
1029
889
  try {
1030
890
  // 清理陈旧 WAL/SHM 残留(正常关闭通常已清理;防御:替换后新库不得复用旧 WAL)
1031
891
  for (const suffix of ["-wal", "-shm"]) {
@@ -1037,11 +897,10 @@ export function overwriteProjectFiles(project, entries, opts = {}) {
1037
897
  throw err;
1038
898
  }
1039
899
  }
1040
- merged = writeProjectFilesFromBackup(project.root, entries, {
900
+ writeProjectFilesFromBackup(project.root, entries, {
1041
901
  keepId: project.config.id,
1042
902
  ...(opts.name !== undefined ? { name: opts.name } : {}),
1043
903
  ...(opts.snapshotFileName !== undefined ? { snapshotFileName: opts.snapshotFileName } : {}),
1044
- ...(opts.mergePackedDirs !== undefined ? { mergePackedDirs: opts.mergePackedDirs } : {}),
1045
904
  });
1046
905
  // 重连 + 版本对齐
1047
906
  let active = openDatabase(dbPath);
@@ -1075,7 +934,6 @@ export function overwriteProjectFiles(project, entries, opts = {}) {
1075
934
  throw err;
1076
935
  }
1077
936
  startAutoBackup(project); // 重启定时器(覆盖包内频率可能不同)
1078
- return merged;
1079
937
  }
1080
938
  /**
1081
939
  * 为**未打开的书**生成覆盖前快照(import 覆盖书架其他书场景,「覆盖前自动快照」):
@@ -1150,7 +1008,7 @@ function writeFileAtomic(filePath, data) {
1150
1008
  *
1151
1009
  * @throws HttpError 400(文件名非法/坏包)、404(备份不存在)、409 SCHEMA_VERSION_MISMATCH
1152
1010
  */
1153
- export function restoreBackup(project, fileName, options = {}) {
1011
+ export function restoreBackup(project, fileName) {
1154
1012
  // 1. 白名单校验(assertBackupFileNameFormat:parseBackupFileName 全格式校验,防路径穿越;
1155
1013
  // 旧命名不可解析 ⇒ 一并被拒)
1156
1014
  assertBackupFileNameFormat(fileName);
@@ -1167,19 +1025,15 @@ export function restoreBackup(project, fileName, options = {}) {
1167
1025
  // 重连 data.db + 同步 config + 重启定时器。
1168
1026
  // name 归一为当前目录名(审核裁决:与 import 覆盖一致,维持「目录名 = 书名」
1169
1027
  // 不变式——id 是身份、name 是展示名;改名需求走 /project/rename)
1170
- const merged = overwriteProjectFiles(project, entries, {
1028
+ overwriteProjectFiles(project, entries, {
1171
1029
  name: basename(project.root),
1172
1030
  snapshotFileName: snapshot.fileName,
1173
- ...(options.mergePackedDirs !== undefined ? { mergePackedDirs: options.mergePackedDirs } : {}),
1174
1031
  });
1175
- // 5. 会话无归属迁移:对话历史已出库为项目目录内 `sessions/*.jsonl`(迁移 006),归属由
1176
- // 目录表达,不再依赖 data.db 的 project_id——恢复只需覆盖三文件
1177
- // (`sessions/` 的入包与覆盖语义由备份管道承担,见 createBackupZip / writeProjectFilesFromBackup)。
1178
- //:snapshot 仅含 fileName/createdAt(size 属内部信息不暴露)
1179
- // snapshot 仅含 fileName/createdAt(size 属内部信息不暴露);merged 仅在并集合并模式下有值
1032
+ // 5. `sessions/` 不碰:对话历史是纯本地目录(不进任何 zip)——本机会话在恢复里不写不删;
1033
+ // 存量旧包内的 `sessions/**` 条目接受但忽略(见 writeProjectFilesFromBackup)。
1034
+ // snapshot 仅含 fileName/createdAt(size 属内部信息不暴露)
1180
1035
  return {
1181
1036
  snapshot: { fileName: snapshot.fileName, createdAt: snapshot.createdAt },
1182
- ...(merged !== undefined ? { merged } : {}),
1183
1037
  };
1184
1038
  }
1185
1039
  //# sourceMappingURL=backup.js.map