@whispering233/ai-editor-server 0.0.33 → 0.0.38
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/client-dist/assets/{index-oxaM6dHH.css → index-IF0z2BR1.css} +1 -1
- package/client-dist/assets/{index-tfvJfexp.js → index-Sx1BGdOS.js} +196 -196
- package/client-dist/index.html +2 -2
- package/dist/backup.d.ts +135 -25
- package/dist/backup.d.ts.map +1 -1
- package/dist/backup.js +348 -68
- package/dist/backup.js.map +1 -1
- package/dist/cloud/auto-push.d.ts +39 -0
- package/dist/cloud/auto-push.d.ts.map +1 -0
- package/dist/cloud/auto-push.js +145 -0
- package/dist/cloud/auto-push.js.map +1 -0
- package/dist/cloud/device.d.ts +3 -0
- package/dist/cloud/device.d.ts.map +1 -0
- package/dist/cloud/device.js +15 -0
- package/dist/cloud/device.js.map +1 -0
- package/dist/cloud/state.d.ts +59 -0
- package/dist/cloud/state.d.ts.map +1 -0
- package/dist/cloud/state.js +162 -0
- package/dist/cloud/state.js.map +1 -0
- package/dist/cloud/sync.d.ts +102 -0
- package/dist/cloud/sync.d.ts.map +1 -0
- package/dist/cloud/sync.js +447 -0
- package/dist/cloud/sync.js.map +1 -0
- package/dist/cloud/webdav.d.ts +69 -0
- package/dist/cloud/webdav.d.ts.map +1 -0
- package/dist/cloud/webdav.js +326 -0
- package/dist/cloud/webdav.js.map +1 -0
- package/dist/device-name.d.ts +3 -0
- package/dist/device-name.d.ts.map +1 -0
- package/dist/device-name.js +13 -0
- package/dist/device-name.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/middleware/error.d.ts +8 -2
- package/dist/middleware/error.d.ts.map +1 -1
- package/dist/middleware/error.js +18 -1
- package/dist/middleware/error.js.map +1 -1
- package/dist/middleware/project.d.ts +27 -3
- package/dist/middleware/project.d.ts.map +1 -1
- package/dist/middleware/project.js +79 -6
- package/dist/middleware/project.js.map +1 -1
- package/dist/routes/cloud.d.ts +9 -0
- package/dist/routes/cloud.d.ts.map +1 -0
- package/dist/routes/cloud.js +206 -0
- package/dist/routes/cloud.js.map +1 -0
- package/dist/routes/delta.d.ts.map +1 -1
- package/dist/routes/delta.js +20 -2
- package/dist/routes/delta.js.map +1 -1
- package/dist/routes/project.d.ts.map +1 -1
- package/dist/routes/project.js +35 -18
- package/dist/routes/project.js.map +1 -1
- package/dist/routes/relation.d.ts.map +1 -1
- package/dist/routes/relation.js +33 -3
- package/dist/routes/relation.js.map +1 -1
- package/package.json +7 -7
package/dist/backup.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
// 自动备份与恢复(B2.2 + B2.5 + B2.6)
|
|
2
2
|
//
|
|
3
3
|
// 职责:
|
|
4
|
-
// - 备份管道:三文件 + wal_checkpoint → .backups/<YYYYMMDD-HHmmssSSS
|
|
5
|
-
//
|
|
6
|
-
// 自动备份/覆盖前快照纯时间戳、重命名后自动备份落 -a-<名称>)
|
|
4
|
+
// - 备份管道:三文件 + wal_checkpoint → .backups/<YYYYMMDD-HHmmssSSS>-<自动|手动>-<设备>[-<标签>]-人物N-设定N-章N.zip
|
|
5
|
+
// (复用导出打包;毫秒精度;类型段:手动备份落「手动」、自动备份/覆盖前快照落「自动」)
|
|
7
6
|
// - 保留策略:每项目保留最近 MAX_BACKUPS_PER_PROJECT 份,超出删除最旧(含覆盖前快照;
|
|
8
|
-
//
|
|
7
|
+
// **只有唯一命名格式的文件参与**——parseBackupFileName 解析,旧命名份留盘但不识别)
|
|
8
|
+
// - 升级兜底:打开项目时若 .backups/ 有文件但无一可解析(旧命名残留)→ ensureParseableBackup
|
|
9
|
+
// 立即生成一份新格式备份(不重命名旧份:旧份无设备/统计信息,硬补会谎报)
|
|
9
10
|
// - 自动定时器:跟随当前项目生命周期(middleware/project.ts setCurrentProject 挂载启停),
|
|
10
11
|
// 有变更才备份(三文件 + data.db-wal 伴生文件 mtime 与 .backups/ 最新备份时间比较,
|
|
11
|
-
// 无状态、服务重启不丢;F2:WAL 模式写只刷新 -wal,补查避免漏检 data.db
|
|
12
|
+
// 无状态、服务重启不丢;F2:WAL 模式写只刷新 -wal,补查避免漏检 data.db 变更);
|
|
13
|
+
// 频率关闭时回落 `setProjectTick` 注入的兑底间隔(卡 7:自动推送挂在同一条 tick 链上,
|
|
14
|
+
// 按 2h 排程——不新起第二个定时器)
|
|
12
15
|
// - 备份包校验(restore 与 import 共用):zip 解析/白名单/三文件齐全/顶层/
|
|
13
16
|
// data.db user_version 三态分流(绝不静默重建)
|
|
14
17
|
// - restore:fileName 白名单 → 覆盖前自动快照 → 校验 → 原子替换三文件
|
|
@@ -22,8 +25,9 @@ import { basename, dirname, join } from "node:path";
|
|
|
22
25
|
import { Unzip, UnzipInflate, zipSync } from "fflate";
|
|
23
26
|
import { BACKUP_FREQUENCIES, DEFAULT_BACKUP_FREQUENCY_MINUTES, formatBackupFileName, MAX_BACKUPS_PER_PROJECT, MAX_BACKUP_NAME_LENGTH, parseBackupFileName, sanitizeBackupName } from "@whispering233/ai-editor-shared";
|
|
24
27
|
import { PROJECT_EXPORT_FILE_NAMES } from "@whispering233/ai-editor-shared/schemas";
|
|
25
|
-
import { closeDatabase, checkpointWal, DATA_DB_FILE_NAME, ensureSchemaCompatible, getUserVersion, hasMigrationPath, openDatabase, OUTLINE_FILE_NAME, PROJECT_FILE_NAME, readAgentsFile, readProjectFile, SCHEMA_VERSION, writeAgentsFile, } from "@whispering233/ai-editor-db";
|
|
28
|
+
import { AGENTS_FILE_NAME, closeDatabase, checkpointWal, DATA_DB_FILE_NAME, ensureSchemaCompatible, getBackupStats, getUserVersion, hasMigrationPath, openDatabase, OUTLINE_FILE_NAME, PROJECT_FILE_NAME, readAgentsFile, readProjectFile, SCHEMA_VERSION, writeAgentsFile, } from "@whispering233/ai-editor-db";
|
|
26
29
|
import { SESSIONS_DIR_NAME } from "@whispering233/ai-editor-agent";
|
|
30
|
+
import { currentDeviceName } from "./cloud/device.js";
|
|
27
31
|
import { HttpError } from "./middleware/error.js";
|
|
28
32
|
/** 备份目录名(项目目录内 .backups/,随书籍移动自然携带) */
|
|
29
33
|
export const BACKUPS_DIR_NAME = ".backups";
|
|
@@ -35,7 +39,7 @@ const REFERENCE_DIR_NAME = "references";
|
|
|
35
39
|
* 白名单前缀 + 递归打包 + 参与变更判定 + 恢复时整体覆盖。
|
|
36
40
|
* `SESSIONS_DIR_NAME` 取自 agent 包的 pi 运行时(会话目录的唯一事实来源;pi 的 session 文件落在此目录)。
|
|
37
41
|
*/
|
|
38
|
-
const PACKED_DIR_NAMES = [REFERENCE_DIR_NAME, SESSIONS_DIR_NAME];
|
|
42
|
+
export const PACKED_DIR_NAMES = [REFERENCE_DIR_NAME, SESSIONS_DIR_NAME];
|
|
39
43
|
/** 备份包条目白名单判定(三文件 + 打包目录前缀——逐名比对天然防 zip 路径穿越;
|
|
40
44
|
* 目录子路径拒绝含 `..` 的条目防相对路径逃逸) */
|
|
41
45
|
export function isAllowedBackupEntry(name) {
|
|
@@ -81,6 +85,63 @@ function overwriteDirFromEntries(dir, dirName, entries) {
|
|
|
81
85
|
writeFileAtomic(target, entries[key]);
|
|
82
86
|
}
|
|
83
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* 用备份条目与本机现有文件做**并集合并**(云拉取语义;本地 restore 不走这里):
|
|
90
|
+
* 逐文件三方比较(本机 / 云端那份 / 基线 `baseEntries`)——
|
|
91
|
+
*
|
|
92
|
+
* | 本机 | 云端 | 基线 | 动作 |
|
|
93
|
+
* | 有 | 有 | — | 写云端那份(同名 = 同一资产) |
|
|
94
|
+
* | 有 | 无 | ✔ | 删本机(云端删除传播) |
|
|
95
|
+
* | 有 | 无 | ✘ | 保留本机(本机新增) |
|
|
96
|
+
* | 无 | 有 | ✔ | **不写回**(本机删除优先,不被复活) |
|
|
97
|
+
* | 无 | 有 | ✘ | 写进来(云端新增) |
|
|
98
|
+
*
|
|
99
|
+
* 基线由 `cloud.json` 的 `books[<id>].baseEntries`(= 最近一次推送/拉取包的条目)提供。
|
|
100
|
+
*/
|
|
101
|
+
function mergeDirFromEntries(dir, dirName, entries, baseEntries) {
|
|
102
|
+
const baseDir = join(dir, dirName);
|
|
103
|
+
const rel = (key) => key.slice(dirName.length + 1);
|
|
104
|
+
const localSet = new Set(listDirFiles(dir, dirName));
|
|
105
|
+
const cloudKeys = Object.keys(entries).filter((k) => k.startsWith(`${dirName}/`) && rel(k) !== "");
|
|
106
|
+
const cloudSet = new Set(cloudKeys.map(rel));
|
|
107
|
+
const baseSet = new Set(baseEntries.filter((e) => e.startsWith(`${dirName}/`) && rel(e) !== "").map(rel));
|
|
108
|
+
const counts = { kept: 0, written: 0, removed: 0 };
|
|
109
|
+
const safeSegments = (path) => {
|
|
110
|
+
const segments = path.split("/");
|
|
111
|
+
return segments.includes("..") ? null : segments; // 白名单已校验,双保险
|
|
112
|
+
};
|
|
113
|
+
// 本机侧:云端没有的份,按基线判「云端删了它」还是「本机新增」
|
|
114
|
+
for (const path of localSet) {
|
|
115
|
+
if (cloudSet.has(path))
|
|
116
|
+
continue; // 两边都有 → 由云端侧写云端那份
|
|
117
|
+
const segments = safeSegments(path);
|
|
118
|
+
if (segments === null)
|
|
119
|
+
continue;
|
|
120
|
+
if (baseSet.has(path)) {
|
|
121
|
+
rmSync(join(baseDir, ...segments), { force: true });
|
|
122
|
+
counts.removed += 1;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
counts.kept += 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// 云端侧:本机没有的份,按基线判「本机删了它」(不复活)还是「云端新增」
|
|
129
|
+
for (const key of cloudKeys) {
|
|
130
|
+
const path = rel(key);
|
|
131
|
+
const segments = safeSegments(path);
|
|
132
|
+
if (segments === null)
|
|
133
|
+
continue;
|
|
134
|
+
const isLocalNew = !localSet.has(path);
|
|
135
|
+
if (isLocalNew && baseSet.has(path))
|
|
136
|
+
continue; // 本机删除优先
|
|
137
|
+
const target = join(baseDir, ...segments);
|
|
138
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
139
|
+
writeFileAtomic(target, entries[key]);
|
|
140
|
+
if (isLocalNew)
|
|
141
|
+
counts.written += 1;
|
|
142
|
+
}
|
|
143
|
+
return counts;
|
|
144
|
+
}
|
|
84
145
|
/** 解压总字节预算(200MB,zip 炸弹防御——与 import 同款,restore 复用) */
|
|
85
146
|
const MAX_UNZIP_BUDGET = 200 * 1024 * 1024;
|
|
86
147
|
/**
|
|
@@ -93,16 +154,16 @@ const MAX_UNZIP_BUDGET = 200 * 1024 * 1024;
|
|
|
93
154
|
const BACKUP_CHANGE_TOLERANCE_MS = 1000;
|
|
94
155
|
/**
|
|
95
156
|
* 备份文件名白名单校验(renameBackup / restoreBackup 共用,防路径穿越):
|
|
96
|
-
* parseBackupFileName 全格式校验(时间戳部分 ^$ 锚定纯数字 +
|
|
97
|
-
* 天然防路径分隔符与 `..`
|
|
157
|
+
* parseBackupFileName 全格式校验(时间戳部分 ^$ 锚定纯数字 + 设备/标签部分拒绝 /\\,
|
|
158
|
+
* 天然防路径分隔符与 `..` 穿越;旧命名不可解析 ⇒ 同样被拒);非法 → 400 VALIDATION_ERROR(文案统一)。
|
|
98
159
|
*
|
|
99
|
-
* @returns 解析结果(校验通过即返回非 null——调用方直接取 time/kind/name,免二次解析)
|
|
160
|
+
* @returns 解析结果(校验通过即返回非 null——调用方直接取 time/kind/name/device/stats,免二次解析)
|
|
100
161
|
* @throws HttpError 400 VALIDATION_ERROR(文件名格式非法)
|
|
101
162
|
*/
|
|
102
163
|
function assertBackupFileNameFormat(fileName) {
|
|
103
164
|
const parsed = parseBackupFileName(fileName);
|
|
104
165
|
if (parsed === null) {
|
|
105
|
-
throw new HttpError(400, "VALIDATION_ERROR", `备份文件名格式非法(仅接受 <YYYYMMDD-HHmmssSSS
|
|
166
|
+
throw new HttpError(400, "VALIDATION_ERROR", `备份文件名格式非法(仅接受 <YYYYMMDD-HHmmssSSS>-<自动|手动>-<设备>[-<标签>]-人物N-设定N-章N.zip): ${fileName}`);
|
|
106
167
|
}
|
|
107
168
|
return parsed;
|
|
108
169
|
}
|
|
@@ -131,38 +192,33 @@ export function createBackupZip(project) {
|
|
|
131
192
|
return zipSync(zipEntries, { level: 6 });
|
|
132
193
|
}
|
|
133
194
|
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* 同毫秒冲突(如「立即备份 + restore 覆盖前快照」连续触发,理论罕见)处理:
|
|
139
|
-
* 时间戳 +1 毫秒循环去重,**保持 <YYYYMMDD-HHmmssSSS>[-<kind>][-<名称>].zip 格式**——
|
|
140
|
-
* parseBackupFileName 解析与 restore 白名单校验不受影响。
|
|
195
|
+
* 生成不冲突的备份文件名:`<时间戳>-<类型>-<设备>[-<标签>]-人物N-设定N-章N.zip`(毫秒精度 +
|
|
196
|
+
* 设备段 + 统计段;shared formatBackupFileName)。同毫秒冲突(如「立即备份 + restore 覆盖前
|
|
197
|
+
* 快照」连续触发,理论罕见)处理:时间戳 +1 毫秒循环去重,**保持格式契约可解析**。
|
|
141
198
|
*
|
|
142
199
|
* @returns { fileName, date }——date 为最终去重后的时间戳(与 fileName 时间戳段一致,
|
|
143
200
|
* 调用方直接用于构造 createdAt,免 parse 回读)
|
|
144
201
|
*/
|
|
145
202
|
function uniqueBackupFileName(backupsDir, date, opts) {
|
|
146
|
-
const kind =
|
|
147
|
-
|
|
148
|
-
let fileName = formatBackupFileName(date, { kind, name });
|
|
203
|
+
const { kind = "auto", name, device, stats } = opts;
|
|
204
|
+
let fileName = formatBackupFileName(date, { kind, name, device, stats });
|
|
149
205
|
while (existsSync(join(backupsDir, fileName))) {
|
|
150
206
|
date = new Date(date.getTime() + 1);
|
|
151
|
-
fileName = formatBackupFileName(date, { kind, name });
|
|
207
|
+
fileName = formatBackupFileName(date, { kind, name, device, stats });
|
|
152
208
|
}
|
|
153
209
|
return { fileName, date };
|
|
154
210
|
}
|
|
155
211
|
/**
|
|
156
212
|
* 立即备份当前项目(手动触发 / 自动定时器 / restore 覆盖前快照共用):
|
|
157
|
-
* 打包 → 写入 .backups
|
|
158
|
-
* 触发保留策略清理(失败不阻塞)。
|
|
213
|
+
* 打包 → 写入 .backups/<时间戳>-<自动|手动>-<设备>[-<标签>]-人物N-设定N-章N.zip
|
|
214
|
+
* (唯一命名格式,毫秒精度;同毫秒 +1ms 去重)→ 触发保留策略清理(失败不阻塞)。
|
|
159
215
|
* 写盘失败向上抛(errorHandler → 500 INTERNAL_ERROR,POST /backup 语义)。
|
|
160
216
|
*
|
|
161
|
-
* @param opts.kind 备份类型(缺省 "auto"):手动触发传 "manual"
|
|
162
|
-
* 自动备份/覆盖前快照不传(auto
|
|
217
|
+
* @param opts.kind 备份类型(缺省 "auto"):手动触发传 "manual"(文件名落「手动」段);
|
|
218
|
+
* 自动备份/覆盖前快照不传(auto,「自动」段)
|
|
163
219
|
* @param opts.name 手动备份自定义名称(仅带名称的手动/重命名场景传入):
|
|
164
220
|
* sanitizeBackupName 是名称校验/规范化**唯一执行点**——非法(含路径分隔符/超长/纯点)→
|
|
165
|
-
* 400 VALIDATION_ERROR;自动备份/覆盖前快照不传 name
|
|
221
|
+
* 400 VALIDATION_ERROR;自动备份/覆盖前快照不传 name,文件名无标签段。
|
|
166
222
|
* 注意:name 仅在 opts.name !== undefined 且 sanitize 通过后传入 formatBackupFileName。
|
|
167
223
|
*/
|
|
168
224
|
export function writeBackup(project, opts) {
|
|
@@ -175,10 +231,14 @@ export function writeBackup(project, opts) {
|
|
|
175
231
|
}
|
|
176
232
|
name = sanitized;
|
|
177
233
|
}
|
|
234
|
+
// 统计快照在打包前取(同一连接同一时刻视角);设备名 = 当前生效值
|
|
235
|
+
//(云端配置的 device 优先,缺省 = 本机 hostname 派生,见 cloud/device.ts)
|
|
236
|
+
const stats = getBackupStats(project.db, project.root);
|
|
237
|
+
const device = currentDeviceName();
|
|
178
238
|
const zip = createBackupZip(project);
|
|
179
239
|
const backupsDir = join(project.root, BACKUPS_DIR_NAME);
|
|
180
240
|
mkdirSync(backupsDir, { recursive: true });
|
|
181
|
-
const { fileName, date } = uniqueBackupFileName(backupsDir, new Date(), { kind, name });
|
|
241
|
+
const { fileName, date } = uniqueBackupFileName(backupsDir, new Date(), { kind, name, device, stats });
|
|
182
242
|
writeFileSync(join(backupsDir, fileName), zip); // 失败抛错 → 500(不产出半截备份)
|
|
183
243
|
pruneBackups(backupsDir); // 清理失败仅记日志,不阻塞备份主流程
|
|
184
244
|
return {
|
|
@@ -187,25 +247,64 @@ export function writeBackup(project, opts) {
|
|
|
187
247
|
createdAt: toIso(date), // date = 最终去重后的时间戳,与 fileName 时间戳段一致(无状态语义)
|
|
188
248
|
kind,
|
|
189
249
|
...(name !== undefined ? { name } : {}),
|
|
250
|
+
device,
|
|
251
|
+
stats,
|
|
190
252
|
};
|
|
191
253
|
}
|
|
192
254
|
/**
|
|
193
|
-
*
|
|
255
|
+
* 升级兜底(卡 A):`.backups/` 里**有文件但没有任何一份可解析**(旧命名残留形态)→
|
|
256
|
+
* 立即 `writeBackup(project, { kind: "auto" })` 生成一份新格式备份并返回 true;
|
|
257
|
+
* 已有可解析份(或目录为空/不存在)→ 什么都不做,返回 false(**不重复备份**)。
|
|
258
|
+
*
|
|
259
|
+
* 为什么需要:旧命名份不再被解析(列表/恢复/保留策略均不认),若磁盘上只剩旧命名且
|
|
260
|
+
* **自动备份频率关闭**,用户点「立即备份」前列表会是空的;这一调用保证最新数据马上有一份
|
|
261
|
+
* 新格式可列/可恢复的档。**不重命名旧份**——旧份无设备/统计信息,硬补会谎报(统计必须描述该备份的内容)。
|
|
194
262
|
*
|
|
263
|
+
* best-effort:任何异常只记日志并返回 false(打开项目不得因兜底备份失败而失败)。
|
|
195
264
|
*
|
|
196
|
-
*
|
|
197
|
-
|
|
198
|
-
|
|
265
|
+
* @returns 本次是否补了一份新格式备份
|
|
266
|
+
*/
|
|
267
|
+
export function ensureParseableBackup(project) {
|
|
268
|
+
try {
|
|
269
|
+
let files;
|
|
270
|
+
try {
|
|
271
|
+
files = readdirSync(join(project.root, BACKUPS_DIR_NAME), { withFileTypes: true })
|
|
272
|
+
.filter((e) => e.isFile())
|
|
273
|
+
.map((e) => e.name);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return false; // 目录不存在/不可读 → 没有「有文件但无一可解析」的形态
|
|
277
|
+
}
|
|
278
|
+
if (files.length === 0)
|
|
279
|
+
return false;
|
|
280
|
+
if (files.some((f) => parseBackupFileName(f) !== null))
|
|
281
|
+
return false; // 已有可解析份 → 不动
|
|
282
|
+
writeBackup(project, { kind: "auto" });
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
console.error("[backup] 命名迁移兜底备份失败:", err);
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* 重命名备份(POST /project/backup/rename):**只改名称段**,时间戳/类型/设备/统计保持。
|
|
292
|
+
*
|
|
293
|
+
*
|
|
294
|
+
* 1. fileName 白名单校验:仅接受 parseBackupFileName 可解析的唯一命名格式
|
|
295
|
+
* `<YYYYMMDD-HHmmssSSS>-<自动|手动>-<设备>[-<标签>]-人物N-设定N-章N.zip`(^$ 锚定 + 设备/标签
|
|
296
|
+
* 部分拒绝 /\\,天然防路径分隔符与 `..` 穿越;旧命名不可解析 ⇒ 无从改名)→ 非法 400 VALIDATION_ERROR
|
|
199
297
|
* 2. 文件不存在 → 404 VALIDATION_ERROR
|
|
200
298
|
* 3. 新名称解析:请求未传 name → 清除名称段;传空串/纯空白 → 清除名称段;
|
|
201
299
|
* 非空 → sanitizeBackupName 规范化(非法 → 400 VALIDATION_ERROR,文案同 writeBackup)
|
|
202
|
-
* 4. 新文件名 = formatBackupFileName(parsed.time, { kind
|
|
203
|
-
* kind 不随重命名改变(auto
|
|
300
|
+
* 4. 新文件名 = formatBackupFileName(parsed.time, { kind, name: 新名称, device, stats })——
|
|
301
|
+
* kind 不随重命名改变(auto 仍是「自动」段、manual 仍是「手动」段);**设备与统计段保持原份的值**
|
|
302
|
+
* (它们描述该备份的来源与内容,不拿当前项目状态凑)
|
|
204
303
|
* 5. 幂等:新文件名 === 原文件名(如重命名为相同名称)→ 不移动文件,直接返回当前条目
|
|
205
304
|
* (重新 stat 取 size;stat 失败 → 404「备份不存在」)
|
|
206
305
|
* 6. **目标冲突防御(oracle P1-1)**:新文件名已存在(≠ 原文件)→ 409
|
|
207
|
-
* BACKUP_TARGET_EXISTS——POSIX rename
|
|
208
|
-
*
|
|
306
|
+
* BACKUP_TARGET_EXISTS——POSIX rename 目标存在时静默替换,可达路径:同毫秒双 manual
|
|
307
|
+
* (`<T>-手动-设备-A.zip` 清标签后撞上 `<T>-手动-设备.zip`)等;显式拒绝
|
|
209
308
|
* 7. renameSync 同目录原子改名(失败向上抛 → 500);改名后统一 stat 取 size
|
|
210
309
|
* (改名瞬间被删等竞态 → 404「备份不存在」)
|
|
211
310
|
*
|
|
@@ -230,8 +329,13 @@ export function renameBackup(project, fileName, name) {
|
|
|
230
329
|
if (sanitized !== null)
|
|
231
330
|
nextName = sanitized;
|
|
232
331
|
}
|
|
233
|
-
// 3.
|
|
234
|
-
const newFileName = formatBackupFileName(parsed.time, {
|
|
332
|
+
// 3. 新文件名(时间戳/类型/设备/统计段保持原备份——重命名只改标签段)
|
|
333
|
+
const newFileName = formatBackupFileName(parsed.time, {
|
|
334
|
+
kind: parsed.kind,
|
|
335
|
+
name: nextName,
|
|
336
|
+
device: parsed.device,
|
|
337
|
+
stats: parsed.stats,
|
|
338
|
+
});
|
|
235
339
|
// 4. 幂等:名称未变 → 不移动文件,返回当前条目(重新 stat 取 size;读不到 → 404)
|
|
236
340
|
if (newFileName === fileName) {
|
|
237
341
|
try {
|
|
@@ -241,6 +345,8 @@ export function renameBackup(project, fileName, name) {
|
|
|
241
345
|
createdAt: toIso(parsed.time),
|
|
242
346
|
kind: parsed.kind,
|
|
243
347
|
...(nextName !== undefined ? { name: nextName } : {}),
|
|
348
|
+
device: parsed.device,
|
|
349
|
+
stats: parsed.stats,
|
|
244
350
|
};
|
|
245
351
|
}
|
|
246
352
|
catch {
|
|
@@ -248,7 +354,7 @@ export function renameBackup(project, fileName, name) {
|
|
|
248
354
|
}
|
|
249
355
|
}
|
|
250
356
|
// 5. 目标冲突防御(oracle P1-1):POSIX rename 目标存在时静默替换(数据丢失风险)——
|
|
251
|
-
//
|
|
357
|
+
// 可达路径:同毫秒双 manual(`<T>-手动-设备-A.zip` 清标签后撞上 `<T>-手动-设备.zip`)等;
|
|
252
358
|
// 显式 409 拒绝。幂等分支(target === fileName)已在第 4 步提前返回,此处必然 target ≠ fileName。
|
|
253
359
|
if (existsSync(join(backupsDir, newFileName))) {
|
|
254
360
|
throw new HttpError(409, "BACKUP_TARGET_EXISTS", `目标备份文件名已存在: ${newFileName}`);
|
|
@@ -269,6 +375,8 @@ export function renameBackup(project, fileName, name) {
|
|
|
269
375
|
createdAt: toIso(parsed.time),
|
|
270
376
|
kind: parsed.kind,
|
|
271
377
|
...(nextName !== undefined ? { name: nextName } : {}),
|
|
378
|
+
device: parsed.device,
|
|
379
|
+
stats: parsed.stats,
|
|
272
380
|
};
|
|
273
381
|
}
|
|
274
382
|
/**
|
|
@@ -322,6 +430,8 @@ export function listBackups(project) {
|
|
|
322
430
|
createdAt: toIso(parsed.time),
|
|
323
431
|
kind: parsed.kind,
|
|
324
432
|
...(parsed.name !== undefined ? { name: parsed.name } : {}),
|
|
433
|
+
device: parsed.device,
|
|
434
|
+
stats: parsed.stats,
|
|
325
435
|
};
|
|
326
436
|
}
|
|
327
437
|
catch {
|
|
@@ -537,15 +647,18 @@ function latestBackupTime(backupsDir) {
|
|
|
537
647
|
return latest;
|
|
538
648
|
}
|
|
539
649
|
/**
|
|
540
|
-
* 三文件 + data.db-wal
|
|
541
|
-
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
* mtime
|
|
650
|
+
* 三文件 + `data.db-wal` 伴生文件 + 两个打包目录,是否在 since 之后有变更
|
|
651
|
+
*(「任一 mtime 晚于上次备份时刻」):
|
|
652
|
+
* - 三文件:任一 mtime > since + 容差 → 有变更;**主文件缺失 → 视为有变更**(防御:不静默
|
|
653
|
+
* 跳过,让备份管道报错暴露损坏)
|
|
654
|
+
* - `data.db-wal`(F2):普通写事务只追加 `-wal`、主文件 mtime 不变;**wal 缺失 ≠ 变更**
|
|
655
|
+
* (无未 checkpoint 的写,属正常状态,否则每次 tick 都误备份)
|
|
656
|
+
* - `references/` 与 `sessions/`:目录内**任一文件** mtime 命中,或**目录自身** mtime 命中
|
|
657
|
+
* (F3:删除文件不刷新任何剩余文件的 mtime,只看文件会漏检删除);目录缺失(ENOENT)
|
|
658
|
+
* = 无文件、不算变更;其余错误防御视为有变更
|
|
659
|
+
* mtime 判定容差见 BACKUP_CHANGE_TOLERANCE_MS 注释。
|
|
547
660
|
*/
|
|
548
|
-
function hasFileChangesSince(project, since) {
|
|
661
|
+
export function hasFileChangesSince(project, since) {
|
|
549
662
|
const limit = since.getTime() + BACKUP_CHANGE_TOLERANCE_MS;
|
|
550
663
|
for (const name of [PROJECT_FILE_NAME, OUTLINE_FILE_NAME, DATA_DB_FILE_NAME]) {
|
|
551
664
|
try {
|
|
@@ -568,11 +681,19 @@ function hasFileChangesSince(project, since) {
|
|
|
568
681
|
if (err.code !== "ENOENT")
|
|
569
682
|
return true;
|
|
570
683
|
}
|
|
571
|
-
// 打包目录(references/ 与 sessions
|
|
572
|
-
// 本地新增/外部编辑 md
|
|
684
|
+
// 打包目录(references/ 与 sessions/):目录自身或其内任一文件 mtime 晚于 limit → 有变更——
|
|
685
|
+
// 本地新增/外部编辑 md 文档、新增聊天会话、**删除文件**同样触发自动备份;
|
|
573
686
|
// 遍历竞态(读取中删除)→ 防御视为无变更(下一 tick 重检)
|
|
574
687
|
try {
|
|
575
688
|
for (const dirName of PACKED_DIR_NAMES) {
|
|
689
|
+
try {
|
|
690
|
+
if (statSync(join(project.root, dirName)).mtimeMs > limit)
|
|
691
|
+
return true;
|
|
692
|
+
}
|
|
693
|
+
catch (err) {
|
|
694
|
+
if (err.code !== "ENOENT")
|
|
695
|
+
return true;
|
|
696
|
+
}
|
|
576
697
|
for (const rel of listDirFiles(project.root, dirName)) {
|
|
577
698
|
if (statSync(join(project.root, dirName, rel)).mtimeMs > limit)
|
|
578
699
|
return true;
|
|
@@ -584,6 +705,120 @@ function hasFileChangesSince(project, since) {
|
|
|
584
705
|
}
|
|
585
706
|
return false;
|
|
586
707
|
}
|
|
708
|
+
/**
|
|
709
|
+
* mtime 变更判定内核(`hasLocalEditsSince` 与 `hasAuthoringChangesSince` 共用,避免两份漂移):
|
|
710
|
+
* 三文件(`data.db`/`-wal` 带 `BACKUP_CHANGE_TOLERANCE_MS` 容差,其余**严格** `>`)→ 额外单文件
|
|
711
|
+
* (`scope.extraFiles`,严格;ENOENT = 该可选文件不存在,不算变更)→ `scope.dirs` 各目录
|
|
712
|
+
* (**目录自身**与其内任一文件 mtime 命中即算「有改动」——删除文件不刷新剩余文件 mtime,
|
|
713
|
+
* 只看文件会漏检删除;目录缺失 = 无文件,不算变更)。
|
|
714
|
+
*
|
|
715
|
+
* 容差只给 `data.db` 与 `-wal` 的原因:备份/推送管道内的 `wal_checkpoint` 会把其 mtime 刷到
|
|
716
|
+
* 同步时刻,严格比较会自激误判「永远有改动」;其余文件不会被管道写入,给容差反而会让
|
|
717
|
+
* 「基准后 1 秒内的改动」被长期漏判(基准是不推进的固定值,直到下次同步才前移)。
|
|
718
|
+
*/
|
|
719
|
+
function hasChangesSince(project, since, scope) {
|
|
720
|
+
const strictLimit = since.getTime();
|
|
721
|
+
const tolerantLimit = strictLimit + BACKUP_CHANGE_TOLERANCE_MS;
|
|
722
|
+
for (const name of [PROJECT_FILE_NAME, OUTLINE_FILE_NAME]) {
|
|
723
|
+
try {
|
|
724
|
+
if (statSync(join(project.root, name)).mtimeMs > strictLimit)
|
|
725
|
+
return true;
|
|
726
|
+
}
|
|
727
|
+
catch {
|
|
728
|
+
return true; // 主文件缺失 = 损坏,视为有改动(不静默)
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
try {
|
|
732
|
+
if (statSync(join(project.root, DATA_DB_FILE_NAME)).mtimeMs > tolerantLimit)
|
|
733
|
+
return true;
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
return true;
|
|
737
|
+
}
|
|
738
|
+
const walPath = join(project.root, `${DATA_DB_FILE_NAME}-wal`);
|
|
739
|
+
try {
|
|
740
|
+
if (statSync(walPath).mtimeMs > tolerantLimit)
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
catch (err) {
|
|
744
|
+
if (err.code !== "ENOENT")
|
|
745
|
+
return true;
|
|
746
|
+
}
|
|
747
|
+
for (const name of scope.extraFiles ?? []) {
|
|
748
|
+
try {
|
|
749
|
+
if (statSync(join(project.root, name)).mtimeMs > strictLimit)
|
|
750
|
+
return true;
|
|
751
|
+
}
|
|
752
|
+
catch (err) {
|
|
753
|
+
if (err.code !== "ENOENT")
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
try {
|
|
758
|
+
for (const dirName of scope.dirs) {
|
|
759
|
+
try {
|
|
760
|
+
if (statSync(join(project.root, dirName)).mtimeMs > strictLimit)
|
|
761
|
+
return true;
|
|
762
|
+
}
|
|
763
|
+
catch (err) {
|
|
764
|
+
if (err.code !== "ENOENT")
|
|
765
|
+
return true;
|
|
766
|
+
}
|
|
767
|
+
for (const rel of listDirFiles(project.root, dirName)) {
|
|
768
|
+
if (statSync(join(project.root, dirName, rel)).mtimeMs > strictLimit)
|
|
769
|
+
return true;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
// 遍历竞态(读取中删除):下一次状态检查重检
|
|
775
|
+
}
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* 「本机创作数据自 since 后有改动」判定(**云端三态专用**,与备份定时器的 `hasFileChangesSince`
|
|
780
|
+
* 口径刻意不同——见 `docs/design/40-cloud-sync.md` §3):
|
|
781
|
+
*
|
|
782
|
+
* - `project.json` / `outline.json` / `references/` / `sessions/`(含两目录自身 mtime):
|
|
783
|
+
* **严格** `mtime > since`。这些文件不会被备份/同步管道写入,容差在这里只有代价:
|
|
784
|
+
* `lastSyncAt` 是**不推进的固定基准**(只在下一次同步时前移),容差会把 `[since, since+1s]`
|
|
785
|
+
* 内的本机改动**永久漏判**(状态误报「已同步」,用户可能因此拉取覆盖它)。
|
|
786
|
+
* - `data.db` / `data.db-wal`:保留 `BACKUP_CHANGE_TOLERANCE_MS` 容差——备份/推送管道内的
|
|
787
|
+
* `wal_checkpoint` 会把 `data.db` mtime 刷到同步时刻,严格比较会自激误判「永远有改动」。
|
|
788
|
+
*/
|
|
789
|
+
export function hasLocalEditsSince(project, since) {
|
|
790
|
+
return hasChangesSince(project, since, { dirs: PACKED_DIR_NAMES });
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* 「本机**创作数据**自 since 后有改动」判定(**自动推送专用**,卡 7;`docs/design/40-cloud-sync.md` §5):
|
|
794
|
+
* 与 `hasLocalEditsSince` 同一套 mtime 口径,但 `sessions/` **不参与**(会话是聊天产物,纯聊天时段
|
|
795
|
+
* 不该单独烧一次云盘配额——聊天记录会随下一次创作变更的 zip 一起上云,zip 永远含 `sessions/`),
|
|
796
|
+
* 并**纳入 `AGENTS.md`**(项目规则也是创作数据,改规则值得推一次)。
|
|
797
|
+
*/
|
|
798
|
+
export function hasAuthoringChangesSince(project, since) {
|
|
799
|
+
return hasChangesSince(project, since, { dirs: [REFERENCE_DIR_NAME], extraFiles: [AGENTS_FILE_NAME] });
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* 最新一份**可解析**备份的时间(旧命名不算;无 → null)。`hasUnbackedChanges` 的基准,
|
|
803
|
+
* 也用于「升级兜底后是否已有新格式档」的判断。
|
|
804
|
+
*/
|
|
805
|
+
export function latestParseableBackupTime(project) {
|
|
806
|
+
return latestBackupTime(join(project.root, BACKUPS_DIR_NAME));
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* 「本机有改动未进最新备份」(卡 B,`GET /cloud/status` 的 `local.backupStale`):
|
|
810
|
+
* 最新一份本地备份的时间早于最新创作改动(三文件 + `references/` + `sessions/`,口径同
|
|
811
|
+
* `hasLocalEditsSince`,`data.db`/`-wal` 带 1s 容差)。**没有任何可解析备份 → true**
|
|
812
|
+
*(那时推送会 404「没有可推送的备份」,语义上也是「还没有能代表当前的档」)。
|
|
813
|
+
*
|
|
814
|
+
* 用途:自动推送的守卫(有未备份改动时跳过,不做「按需备份」——备份归本地、云端只是镜像)。
|
|
815
|
+
*/
|
|
816
|
+
export function hasUnbackedChanges(project) {
|
|
817
|
+
const latest = latestParseableBackupTime(project);
|
|
818
|
+
if (latest === null)
|
|
819
|
+
return true;
|
|
820
|
+
return hasLocalEditsSince(project, latest);
|
|
821
|
+
}
|
|
587
822
|
/**
|
|
588
823
|
* 自动备份单次检查(定时器 tick 核心,纯同步、可单测):
|
|
589
824
|
*
|
|
@@ -609,11 +844,22 @@ export function maybeAutoBackup(project) {
|
|
|
609
844
|
// ============ 定时器调度(跟随当前项目生命周期,middleware setCurrentProject 挂载) ============
|
|
610
845
|
/** 当前排程的定时器句柄(null = 未排程/已停止) */
|
|
611
846
|
let backupTimer = null;
|
|
847
|
+
/** 已注册的 tick 钩子(缺省空 = 仅备份行为,与卡 7 之前一致) */
|
|
848
|
+
let tickHooks = {};
|
|
849
|
+
/**
|
|
850
|
+
* 注册 tick 钩子(`middleware/project.ts` 启动时调用一次;**整体替换**,测试可注入小间隔)。
|
|
851
|
+
* 用模块级注册而不是每次 `startAutoBackup` 传参:`overwriteProjectFiles` 尾部那次重启
|
|
852
|
+
*(restore / 导入 / 云拉取后)也必须带上同一套钩子。
|
|
853
|
+
*/
|
|
854
|
+
export function setProjectTick(hooks) {
|
|
855
|
+
tickHooks = hooks;
|
|
856
|
+
}
|
|
612
857
|
/**
|
|
613
858
|
* 启动/重启自动备份调度(open/切换项目时调用;restore 后频率可能变化也调用):
|
|
614
|
-
* 按当前项目频率 setTimeout 链——每 tick
|
|
859
|
+
* 按当前项目频率 setTimeout 链——每 tick 检查「有变更才备份」后按最新间隔重新排程
|
|
615
860
|
* (tick 内重读 config,restore 改变频率无需显式通知)。
|
|
616
|
-
* 频率关闭(null/0/非枚举)→
|
|
861
|
+
* 频率关闭(null/0/非枚举)→ 回落到 `tickHooks.fallbackIntervalMs()`(自动推送的 2h 兜底排程);
|
|
862
|
+
* 两者皆空 → 不排程(等价停止)。
|
|
617
863
|
*/
|
|
618
864
|
export function startAutoBackup(project) {
|
|
619
865
|
stopAutoBackup();
|
|
@@ -626,10 +872,14 @@ export function stopAutoBackup() {
|
|
|
626
872
|
backupTimer = null;
|
|
627
873
|
}
|
|
628
874
|
}
|
|
629
|
-
/**
|
|
875
|
+
/**
|
|
876
|
+
* 按项目当前间隔排程下一次检查:
|
|
877
|
+
* 备份频率开启 → `freq * 60_000`;关闭 → `tickHooks.fallbackIntervalMs()`(null = 不排程)。
|
|
878
|
+
*/
|
|
630
879
|
function scheduleNext(project) {
|
|
631
880
|
const freq = resolveBackupFrequency(project.config.backup_frequency_minutes);
|
|
632
|
-
|
|
881
|
+
const intervalMs = freq === null ? (tickHooks.fallbackIntervalMs?.() ?? null) : freq * 60_000;
|
|
882
|
+
if (intervalMs === null)
|
|
633
883
|
return;
|
|
634
884
|
backupTimer = setTimeout(() => {
|
|
635
885
|
backupTimer = null;
|
|
@@ -639,8 +889,14 @@ function scheduleNext(project) {
|
|
|
639
889
|
catch (err) {
|
|
640
890
|
console.error("[backup] 自动备份检查失败:", err);
|
|
641
891
|
}
|
|
642
|
-
|
|
643
|
-
|
|
892
|
+
try {
|
|
893
|
+
tickHooks.onTick?.(project); // 钩子抛错同样不中断调度(自动推送的失败在钩子内部自记状态)
|
|
894
|
+
}
|
|
895
|
+
catch (err) {
|
|
896
|
+
console.error("[backup] 定时器钩子失败:", err);
|
|
897
|
+
}
|
|
898
|
+
scheduleNext(project); // 每次 tick 重读频率与钩子(restore/切换/云端配置可能改变)
|
|
899
|
+
}, intervalMs);
|
|
644
900
|
// unref:定时器不阻止进程退出(测试/服务关闭后无残留句柄)
|
|
645
901
|
backupTimer.unref?.();
|
|
646
902
|
}
|
|
@@ -663,6 +919,7 @@ export function writeProjectFilesFromBackup(dir, entries, opts = {}) {
|
|
|
663
919
|
// (校验已通过,文件内容本身有效,部分替换可经重新恢复修复);replaced 清单供失败日志使用(P1-2)
|
|
664
920
|
const targetNames = [PROJECT_FILE_NAME, OUTLINE_FILE_NAME, DATA_DB_FILE_NAME];
|
|
665
921
|
const replaced = [];
|
|
922
|
+
let merged;
|
|
666
923
|
try {
|
|
667
924
|
const parsedProject = JSON.parse(new TextDecoder().decode(entries[PROJECT_FILE_NAME]));
|
|
668
925
|
const nextProject = {
|
|
@@ -676,10 +933,21 @@ export function writeProjectFilesFromBackup(dir, entries, opts = {}) {
|
|
|
676
933
|
replaced.push(OUTLINE_FILE_NAME);
|
|
677
934
|
writeFileAtomic(join(dir, DATA_DB_FILE_NAME), entries[DATA_DB_FILE_NAME]);
|
|
678
935
|
replaced.push(DATA_DB_FILE_NAME);
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
|
|
682
|
-
|
|
936
|
+
// 打包目录(references/ 含 .trash/、sessions/ 会话 JSONL):
|
|
937
|
+
// - 默认「整体覆盖」——恢复/导入是「整体还原」语义,目录以备份内容为准,本地残留不混入
|
|
938
|
+
// - `mergePackedDirs`(云拉取)= 与基线做三方比较的**并集合并**(删除优先,本机新增保留)
|
|
939
|
+
for (const dirName of PACKED_DIR_NAMES) {
|
|
940
|
+
if (opts.mergePackedDirs === undefined) {
|
|
941
|
+
overwriteDirFromEntries(dir, dirName, entries);
|
|
942
|
+
}
|
|
943
|
+
else {
|
|
944
|
+
const counts = mergeDirFromEntries(dir, dirName, entries, opts.mergePackedDirs.baseEntries);
|
|
945
|
+
merged = merged ?? { kept: 0, written: 0, removed: 0 };
|
|
946
|
+
merged.kept += counts.kept;
|
|
947
|
+
merged.written += counts.written;
|
|
948
|
+
merged.removed += counts.removed;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
683
951
|
}
|
|
684
952
|
catch (err) {
|
|
685
953
|
// P1-2:失败路径日志(对齐「记日志暴露部分替换」承诺)——已替换/未替换文件清单 + 覆盖前快照名
|
|
@@ -687,6 +955,7 @@ export function writeProjectFilesFromBackup(dir, entries, opts = {}) {
|
|
|
687
955
|
console.error(`[backup] 覆盖文件替换失败(已替换: ${replaced.join(", ") || "无"};未替换: ${notReplaced.join(", ") || "无"}${opts.snapshotFileName !== undefined ? `;覆盖前快照: ${opts.snapshotFileName}` : ""})`, err);
|
|
688
956
|
throw err;
|
|
689
957
|
}
|
|
958
|
+
return merged;
|
|
690
959
|
}
|
|
691
960
|
/**
|
|
692
961
|
* 自动迁移:project.json 存在非空 `prompt` 且项目目录无 →
|
|
@@ -739,6 +1008,7 @@ export function migratePromptToAgents(project) {
|
|
|
739
1008
|
export function overwriteProjectFiles(project, entries, opts = {}) {
|
|
740
1009
|
const dbPath = join(project.root, DATA_DB_FILE_NAME);
|
|
741
1010
|
closeDatabase(project.db); // 释放当前连接(替换 data.db 前必须;替换失败恢复见 catch)
|
|
1011
|
+
let merged;
|
|
742
1012
|
try {
|
|
743
1013
|
// 清理陈旧 WAL/SHM 残留(正常关闭通常已清理;防御:替换后新库不得复用旧 WAL)
|
|
744
1014
|
for (const suffix of ["-wal", "-shm"]) {
|
|
@@ -750,10 +1020,11 @@ export function overwriteProjectFiles(project, entries, opts = {}) {
|
|
|
750
1020
|
throw err;
|
|
751
1021
|
}
|
|
752
1022
|
}
|
|
753
|
-
writeProjectFilesFromBackup(project.root, entries, {
|
|
1023
|
+
merged = writeProjectFilesFromBackup(project.root, entries, {
|
|
754
1024
|
keepId: project.config.id,
|
|
755
1025
|
...(opts.name !== undefined ? { name: opts.name } : {}),
|
|
756
1026
|
...(opts.snapshotFileName !== undefined ? { snapshotFileName: opts.snapshotFileName } : {}),
|
|
1027
|
+
...(opts.mergePackedDirs !== undefined ? { mergePackedDirs: opts.mergePackedDirs } : {}),
|
|
757
1028
|
});
|
|
758
1029
|
// 重连 + 版本对齐
|
|
759
1030
|
let active = openDatabase(dbPath);
|
|
@@ -787,6 +1058,7 @@ export function overwriteProjectFiles(project, entries, opts = {}) {
|
|
|
787
1058
|
throw err;
|
|
788
1059
|
}
|
|
789
1060
|
startAutoBackup(project); // 重启定时器(覆盖包内频率可能不同)
|
|
1061
|
+
return merged;
|
|
790
1062
|
}
|
|
791
1063
|
/**
|
|
792
1064
|
* 为**未打开的书**生成覆盖前快照(import 覆盖书架其他书场景,「覆盖前自动快照」):
|
|
@@ -845,10 +1117,10 @@ function writeFileAtomic(filePath, data) {
|
|
|
845
1117
|
/**
|
|
846
1118
|
* 从备份恢复当前项目(POST /project/backup/restore):
|
|
847
1119
|
*
|
|
848
|
-
* 1. fileName 白名单校验:仅允许 `.backups/`
|
|
849
|
-
* `<YYYYMMDD-HHmmssSSS
|
|
850
|
-
*
|
|
851
|
-
*
|
|
1120
|
+
* 1. fileName 白名单校验:仅允许 `.backups/` 下的**唯一命名格式**
|
|
1121
|
+
* `<YYYYMMDD-HHmmssSSS>-<自动|手动>-<设备>[-<标签>]-人物N-设定N-章N.zip`(shared parseBackupFileName,
|
|
1122
|
+
* ^$ 锚定 + 设备/标签部分拒绝 /\\,天然防路径分隔符与 `..` 穿越;旧命名不可解析 ⇒ 一并被拒);
|
|
1123
|
+
* 格式合法但文件不存在 → 404
|
|
852
1124
|
* 2. **覆盖前自动快照**:当前三文件打包为快照存入 .backups/(复用备份管道,
|
|
853
1125
|
* 就是普通备份文件,自然参与保留策略——后悔药)
|
|
854
1126
|
* 3. 备份包校验(validateBackupPackage:zip/白名单//user_version 三态,
|
|
@@ -861,9 +1133,9 @@ function writeFileAtomic(filePath, data) {
|
|
|
861
1133
|
*
|
|
862
1134
|
* @throws HttpError 400(文件名非法/坏包)、404(备份不存在)、409 SCHEMA_VERSION_MISMATCH
|
|
863
1135
|
*/
|
|
864
|
-
export function restoreBackup(project, fileName) {
|
|
1136
|
+
export function restoreBackup(project, fileName, options = {}) {
|
|
865
1137
|
// 1. 白名单校验(assertBackupFileNameFormat:parseBackupFileName 全格式校验,防路径穿越;
|
|
866
|
-
//
|
|
1138
|
+
// 旧命名不可解析 ⇒ 一并被拒)
|
|
867
1139
|
assertBackupFileNameFormat(fileName);
|
|
868
1140
|
const backupPath = join(project.root, BACKUPS_DIR_NAME, fileName);
|
|
869
1141
|
if (!existsSync(backupPath)) {
|
|
@@ -878,11 +1150,19 @@ export function restoreBackup(project, fileName) {
|
|
|
878
1150
|
// 重连 data.db + 同步 config + 重启定时器。
|
|
879
1151
|
// name 归一为当前目录名(审核裁决:与 import 覆盖一致,维持「目录名 = 书名」
|
|
880
1152
|
// 不变式——id 是身份、name 是展示名;改名需求走 /project/rename)
|
|
881
|
-
overwriteProjectFiles(project, entries, {
|
|
1153
|
+
const merged = overwriteProjectFiles(project, entries, {
|
|
1154
|
+
name: basename(project.root),
|
|
1155
|
+
snapshotFileName: snapshot.fileName,
|
|
1156
|
+
...(options.mergePackedDirs !== undefined ? { mergePackedDirs: options.mergePackedDirs } : {}),
|
|
1157
|
+
});
|
|
882
1158
|
// 5. 会话无归属迁移:对话历史已出库为项目目录内 `sessions/*.jsonl`(迁移 006),归属由
|
|
883
1159
|
// 目录表达,不再依赖 data.db 的 project_id——恢复只需覆盖三文件 + references/
|
|
884
1160
|
// (`sessions/` 的入包与覆盖语义由备份管道承担,见 createBackupZip / writeProjectFilesFromBackup)。
|
|
885
1161
|
//:snapshot 仅含 fileName/createdAt(size 属内部信息不暴露)
|
|
886
|
-
|
|
1162
|
+
// snapshot 仅含 fileName/createdAt(size 属内部信息不暴露);merged 仅在并集合并模式下有值
|
|
1163
|
+
return {
|
|
1164
|
+
snapshot: { fileName: snapshot.fileName, createdAt: snapshot.createdAt },
|
|
1165
|
+
...(merged !== undefined ? { merged } : {}),
|
|
1166
|
+
};
|
|
887
1167
|
}
|
|
888
1168
|
//# sourceMappingURL=backup.js.map
|