@ggboy123/dsh-paper-reader 1.3.4 → 1.4.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.
@@ -0,0 +1,752 @@
1
+ // library-manage.ts — 文献库的破坏性操作(删除 / 重命名)与配套的安全校验。
2
+ //
3
+ // 契约:docs/reader-ux-requirements.md §2。要点:
4
+ // · 写路径**只接受** topic + name(不接受 path),逐条做路径穿越防护;
5
+ // · 删除 = 精确 stem 匹配的产物清单,**移入 `<dataDir>/.trash/`**(可恢复),
6
+ // 移动失败绝不回退 unlink,`.pdf` 最后移(失败时文献仍在库里可见可用);
7
+ // · 删除专题只允许空目录,非递归语义(rmdir),隐藏条目进回收站而不是销毁;
8
+ // · 重命名先校验全部前提再动手,中途失败 best-effort 回滚。
9
+ //
10
+ // 本模块不依赖 Cordis,可直接被测试 import(dist/library-manage.js)。
11
+ import { lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, writeFileSync, existsSync } from 'node:fs';
12
+ // 共享的**边界原语**(上游 #4 的路径穿越修复,逐字保留在 library.ts):
13
+ // validateEntryName / validateEntryBoundary / realDataDir / assertInside。
14
+ // 注意口径(评审 F-F):组合层**两套并存**——生产写路径用本模块的 resolveTopicDirChecked
15
+ // (在其上更严),#4 的 resolveSafeTopic 组合层只被 library.ts 的适配器(上游测试用)调用。
16
+ // 本模块**消费**它,不再自己重复一套词法/realpath 校验;我们的加严叠在其上(见下)。
17
+ import { assertInside as assertInsideShared, listPapers, realDataDir as realDataDirShared, validateEntryBoundary as validateBoundaryShared, validateEntryName, } from "./library.js";
18
+ import { basename, dirname, join, resolve } from 'node:path';
19
+ import { randomBytes } from 'node:crypto';
20
+ /** HTTP 状态与机器可读 code 双携带;`extra` 会并入响应体(前端只认 code,不匹配中文)。 */
21
+ export class ManageError extends Error {
22
+ code;
23
+ status;
24
+ extra;
25
+ constructor(code, status, message, extra = {}) {
26
+ super(message);
27
+ this.name = 'ManageError';
28
+ this.code = code;
29
+ this.status = status;
30
+ this.extra = extra;
31
+ }
32
+ }
33
+ export function isManageError(e) {
34
+ return e instanceof ManageError;
35
+ }
36
+ // ── 名称校验 ────────────────────────────────────────────────────────────────
37
+ /** 回收站目录名(保留名;`listTopics` 因隐藏而跳过它,因此不会显示成专题)。 */
38
+ export const TRASH_DIR = '.trash';
39
+ /**
40
+ * 命名规范类校验(创建 / 改名-to 用)——**复用上游 #4 的 `validateEntryName`**,
41
+ * 只把 `string | null` 的形状映射成我们的 `ManageError('bad-name', 400, 文案)`;
42
+ * 文案沿用 #4 原句(上游用例按正则断言:/名字过长/、/名字不能包含控制字符/…)。
43
+ * 语义与 #4 完全一致:trim 后使用(`' T '` 落到 `T`)、长度 ≤ 120、拒绝全部 C0 控制字符
44
+ * 与 DEL、拒绝前导点、拒绝 `/ \ ..`。
45
+ */
46
+ export function validateName(raw, label) {
47
+ const what = label === 'topic' ? '专题名' : '文献名';
48
+ if (typeof raw !== 'string')
49
+ throw new ManageError('bad-name', 400, `${what}不能为空`);
50
+ const err = validateEntryName(raw);
51
+ if (err)
52
+ throw new ManageError('bad-name', 400, err);
53
+ return raw.trim();
54
+ }
55
+ /**
56
+ * 边界类校验(删除 / 改名-from / 已存在条目用)——**复用上游 #4 的 `validateEntryBoundary`**
57
+ * (空、`/ \`、`..`、NUL),并叠上我们的两条加严:`.trash` 是保留名、`basename` 必须一致。
58
+ *
59
+ * 与 `validateName`(命名规范类:长度、前导点、非 NUL 控制字符)的分工是 PR-1 的分层原则:
60
+ * 命名规范只在**创建/改名**时强制(那是用户在新起一个名字);删除/改名-from 作用于
61
+ * **已存在**的条目,若也按命名规范卡,「创建时宽松、事后规范收紧」或历史遗留的名字
62
+ * (前导点、超长、`\x01`)就会变成删不掉的孤儿。反之,upload 能落盘的名字都必须能删。
63
+ *
64
+ * 原样返回 raw(**不 trim**),与创建侧的 trim 语义分开:按字面名匹配磁盘条目。
65
+ */
66
+ export function validateEntryBoundary(raw, label) {
67
+ const what = label === 'topic' ? '专题名' : '文献名';
68
+ if (typeof raw !== 'string' || raw.length === 0)
69
+ throw new ManageError('bad-name', 400, `${what}不能为空`);
70
+ const err = validateBoundaryShared(raw);
71
+ if (err)
72
+ throw new ManageError('bad-name', 400, err);
73
+ if (basename(raw) !== raw)
74
+ throw new ManageError('bad-name', 400, `${what}非法`);
75
+ // `.` / `..` 不是任何条目的名字(上游边界类只挡 `..`,`basename` 归一后是 `.` 的形态也一并挡掉;
76
+ // 这是我们的加严——realpath 层随后还会拒 target === root,两道都在)。
77
+ if (basename(raw) === '.' || basename(raw) === '..')
78
+ throw new ManageError('bad-name', 400, `${what}非法`);
79
+ // 回收站是插件自己的保留目录(listTopics 因隐藏而跳过它,用户也创建不了):
80
+ // 不允许把它当专题删掉/改名,否则回收站里的可恢复文件会被误销毁。
81
+ if (raw === TRASH_DIR)
82
+ throw new ManageError('bad-name', 400, `${what}是保留名,不能操作`);
83
+ return raw;
84
+ }
85
+ /** 文献名不得以 pdf2zh 变体后缀结尾(否则 isPaperPDF 视其为非文献 → 从库里消失)。 */
86
+ export function validateNewPaperName(raw, current) {
87
+ const name = validateName(raw, 'paper');
88
+ if (name === current)
89
+ throw new ManageError('bad-request', 400, '新名称与原名称相同');
90
+ if (/-(en|zh|dual)$/.test(name)) {
91
+ throw new ManageError('bad-name', 400, '新名称不能以 -en/-zh/-dual 结尾(会被当作 pdf2zh 变体而从文献库隐藏)');
92
+ }
93
+ return name;
94
+ }
95
+ // ── 路径解析与穿越防护 ───────────────────────────────────────────────────────
96
+ /**
97
+ * target 必须落在 root 内(严格子路径),否则视为越界。
98
+ * **复用上游 #4 的 `assertInside`**(带 sep 边界的严格前缀比较),并在其上叠我们的一条加严:
99
+ * 上游允许 `target === root`(`target !== root && !startsWith`),我们**拒绝**——对
100
+ * deleteTopic/rename 这类破坏性操作,把「库根目录本身」当专题名操作必须挡掉
101
+ * (既有用例钉住:reader-ux-verification 的 VB5 ⑤)。
102
+ */
103
+ export function assertInside(root, target) {
104
+ try {
105
+ assertInsideShared(root, target); // 上游 #4 的严格子路径包含
106
+ }
107
+ catch {
108
+ // 上游抛的是不带 .code 的普通 Error → 统一映射成带机器码的 ManageError(host 的错误通道认 code)
109
+ throw new ManageError('escape-rejected', 400, '路径越界,已拒绝');
110
+ }
111
+ if (target === root)
112
+ throw new ManageError('escape-rejected', 400, '路径越界,已拒绝');
113
+ }
114
+ /** 文献库根目录的真实路径(必须存在)——复用 #4 的 realpath 规范化,错误映射成我们的 404。 */
115
+ export function realDataDir(dataDir) {
116
+ try {
117
+ return realDataDirShared(dataDir);
118
+ }
119
+ catch {
120
+ throw new ManageError('not-found', 404, '文献库目录不存在');
121
+ }
122
+ }
123
+ /** 解析专题目录的公共实现:`boundaryOnly` 选择词法校验的强度(见 validateEntryBoundary)。 */
124
+ function resolveTopicDirChecked(dataDir, topic, boundaryOnly) {
125
+ const name = boundaryOnly ? validateEntryBoundary(topic, 'topic') : validateName(topic, 'topic');
126
+ const root = realDataDir(dataDir);
127
+ const dir = join(root, name);
128
+ let st;
129
+ try {
130
+ st = lstatSync(dir);
131
+ }
132
+ catch {
133
+ throw new ManageError('not-found', 404, `专题不存在:${name}`);
134
+ }
135
+ if (st.isSymbolicLink())
136
+ throw new ManageError('escape-rejected', 400, `路径越界:专题目录是符号链接,已拒绝:${name}`);
137
+ if (!st.isDirectory())
138
+ throw new ManageError('not-found', 404, `专题不存在:${name}`);
139
+ const real = realpathSync(dir);
140
+ if (real !== dir)
141
+ throw new ManageError('escape-rejected', 400, `路径越界:专题目录真实路径异常,已拒绝:${name}`);
142
+ assertInside(root, real);
143
+ return real;
144
+ }
145
+ /** 解析专题目录(写入路径用):必须存在、必须是真目录(不是符号链接)、必须落在 dataDir 内。 */
146
+ export function resolveTopicDir(dataDir, topic) {
147
+ return resolveTopicDirChecked(dataDir, topic, false);
148
+ }
149
+ /** 解析**已存在**的专题目录(删除 / 改名-from 用):只做边界类校验,历史遗留的
150
+ * 不规范名(前导点、超长、控制字符)同样能操作,避免「删不掉的孤儿」。 */
151
+ export function resolveExistingTopicDir(dataDir, topic) {
152
+ return resolveTopicDirChecked(dataDir, topic, true);
153
+ }
154
+ // ── 产物清单(精确 stem 匹配) ────────────────────────────────────────────────
155
+ // `.study.json` = 论文伴读的学习档案(阅读计划 / 检验成绩,src/study.ts 写在文献旁边;preset 的
156
+ // study_update 工具会真的写它)。上游 v1.3.0 的 PAPER_SIDECARS 覆盖了它、release note 也写明
157
+ // 「转录 / 译文 / 学习档案连带搬家」;整合时上游实现被我们的加固版整段替换,该后缀随之丢失
158
+ // (t4 评审 F1)。少它的后果:① 改名后档案仍挂旧名 → readStudy 找不到,用户以为进度全丢;
159
+ // ② 删除时不进回收站、moved/skipped 都不报告;③ 残留条目既不是文献也不是专题,任何 API 都
160
+ // 清不掉,永久阻塞删专题(409 topic-not-empty)。本次归位:改名连带、删除移入回收站。
161
+ export const PAPER_STEM_SUFFIXES = ['.pdf', '.txt', '.pages.json', '.transcript.json', '.mineru.md', '.mineru.json', '.embeddings.json', '.study.json'];
162
+ export const PDF2ZH_VARIANT_SUFFIXES = ['-en', '-zh', '-dual'];
163
+ /** atomicTempPath(): `${path}.tmp-${pid}-${uuid.slice(0,8)}`(src/transcribe.ts)。 */
164
+ const TMP_RE = /\.tmp-\d+-[0-9a-f]{8}$/;
165
+ /**
166
+ * 精确匹配:`<paperName><已知后缀>` 或 `<paperName>-en|-zh|-dual.pdf` 或上述 + `.tmp-…`。
167
+ * 刻意**不用** startsWith —— 删 `Attention` 绝不能连带删 `Attention Is All You Need.pdf`。
168
+ */
169
+ export function matchArtifactName(paperName, entry) {
170
+ let base = entry;
171
+ const m = TMP_RE.exec(entry);
172
+ if (m)
173
+ base = entry.slice(0, m.index);
174
+ for (const s of PAPER_STEM_SUFFIXES)
175
+ if (base === paperName + s)
176
+ return true;
177
+ for (const v of PDF2ZH_VARIANT_SUFFIXES)
178
+ if (base === paperName + v + '.pdf')
179
+ return true;
180
+ return false;
181
+ }
182
+ function kindOf(paperName, entry) {
183
+ const isTmp = TMP_RE.test(entry);
184
+ let base = entry;
185
+ if (isTmp)
186
+ base = entry.slice(0, TMP_RE.exec(entry).index);
187
+ if (base === paperName + '.pdf')
188
+ return isTmp ? 'tmp' : 'pdf';
189
+ for (const v of PDF2ZH_VARIANT_SUFFIXES)
190
+ if (base === paperName + v + '.pdf')
191
+ return isTmp ? 'tmp' : 'variant';
192
+ if (base === paperName + '.mineru.json' || base === paperName + '.mineru.md')
193
+ return isTmp ? 'tmp' : 'mineru';
194
+ if (base === paperName + '.embeddings.json')
195
+ return isTmp ? 'tmp' : 'embed';
196
+ if (base === paperName + '.study.json')
197
+ return isTmp ? 'tmp' : 'study';
198
+ return isTmp ? 'tmp' : 'cache';
199
+ }
200
+ /** 枚举某文献在专题目录里的产物(只看文件;目录一律忽略)。 */
201
+ export function listArtifacts(topicDir, paperName) {
202
+ const out = [];
203
+ for (const e of readdirSync(topicDir, { withFileTypes: true })) {
204
+ if (e.isDirectory())
205
+ continue;
206
+ if (!matchArtifactName(paperName, e.name))
207
+ continue;
208
+ const p = join(topicDir, e.name);
209
+ let st;
210
+ try {
211
+ st = lstatSync(p);
212
+ }
213
+ catch {
214
+ continue;
215
+ }
216
+ if (st.isDirectory())
217
+ continue;
218
+ out.push({ name: e.name, kind: kindOf(paperName, e.name), bytes: st.size, symlink: st.isSymbolicLink() });
219
+ }
220
+ // 阅读序稳定输出:先变体/缓存,`.pdf` 最后(删除与重命名都靠这个顺序;study 也要排在 .pdf 前)
221
+ const order = ['variant', 'mineru', 'embed', 'study', 'cache', 'tmp', 'pdf'];
222
+ return out.sort((a, b) => (order.indexOf(a.kind) - order.indexOf(b.kind)) || a.name.localeCompare(b.name));
223
+ }
224
+ /** 校验每个目标文件都不越界(符号链接只校验链接自身所在目录)。 */
225
+ export function assertArtifactsContained(dataDir, topicDir, files) {
226
+ const root = realDataDir(dataDir);
227
+ const realTopic = realpathSync(topicDir);
228
+ if (realTopic !== resolve(topicDir))
229
+ throw new ManageError('escape-rejected', 400, '专题目录真实路径异常,已拒绝');
230
+ assertInside(root, realTopic);
231
+ for (const f of files) {
232
+ const p = join(topicDir, f.name);
233
+ if (basename(p) !== f.name || dirname(p) !== topicDir) {
234
+ throw new ManageError('escape-rejected', 400, `文件名非法:${f.name}`);
235
+ }
236
+ if (!f.symlink) {
237
+ // 普通文件:再确认一次真实路径仍在库内(防守硬链接/替换竞态)
238
+ let real;
239
+ try {
240
+ real = realpathSync(p);
241
+ }
242
+ catch {
243
+ continue;
244
+ }
245
+ assertInside(root, real);
246
+ }
247
+ // 符号链接:只移动链接本身,绝不对 readlink 目标动手 —— 不跟随即安全
248
+ }
249
+ }
250
+ function trashStamp() {
251
+ const d = new Date();
252
+ const p = (n, w = 2) => String(n).padStart(w, '0');
253
+ return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}T${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z`;
254
+ }
255
+ /**
256
+ * 准备一个回收站目录。**失败也要有机器可读 code**(t3-F1 / R4-F1):
257
+ * 以前 mkdir 的原生错误会冒泡到 host 的外层兜底 → 500 无 code,且 `EACCES: … '/abs/path'`
258
+ * 直接把 dataDir 的绝对路径写进响应体(违反本模块自述的 S15)。这里统一转成 ManageError,
259
+ * 文案只描述现象,绝对路径只出现在 `trashRel`(相对 dataDir,UI 展示用)。
260
+ */
261
+ export function newTrashBundle(dataDir) {
262
+ const root = realDataDir(dataDir);
263
+ const rel = join('.trash', `${trashStamp()}-${randomBytes(3).toString('hex')}`);
264
+ const abs = join(root, rel);
265
+ try {
266
+ mkdirSync(abs, { recursive: true, mode: 0o700 });
267
+ }
268
+ catch (err) {
269
+ throw new ManageError('trash-move-failed', 500, '无法准备回收站目录,已取消删除(未删除任何文件;请检查文献库目录是否可写)', {
270
+ trashRel: rel,
271
+ cause: err.code ?? 'unknown',
272
+ });
273
+ }
274
+ return { abs, rel };
275
+ }
276
+ /**
277
+ * 把一批文件移入回收站。失败时**不**回退 unlink,而是:
278
+ * ① 先做**反向回滚**——把本次已 rename 进回收站的文件 rename 回原位(rename,不是 unlink;
279
+ * 契约禁的是「删失败就退回真删」,回滚不违反它),让「失败 = 库没变」成为默认结果;
280
+ * ② 再按 errno 分类:EBUSY/EPERM(Windows 文件占用)→ 409 `file-busy` 可执行文案;
281
+ * 其它 errno → 500 `trash-move-failed`。两种情况都带 `MoveFailure` 全量字段。
282
+ * 回滚本身也可能失败(例如目标同时被占用):此时**不静默**——失败的文件仍在回收站里(可恢复),
283
+ * 如实列进 `rollbackFailed` 并给**相对**的 `trashRel`,文案降级为「未能回滚,请到回收站手动恢复」。
284
+ */
285
+ export function moveToTrash(bundle, topicDir, files, renameImpl = renameSync) {
286
+ const moved = [];
287
+ for (const f of files) {
288
+ const from = join(topicDir, f.name);
289
+ try {
290
+ renameImpl(from, join(bundle.abs, f.name));
291
+ moved.push(f.name);
292
+ }
293
+ catch (err) {
294
+ const cause = err.code ?? 'unknown';
295
+ const busy = cause === 'EBUSY' || cause === 'EPERM';
296
+ // ① 反向回滚(best-effort,最近移动的先回;rename 回去,绝不 unlink)
297
+ const rolledBack = [];
298
+ const rollbackFailed = [];
299
+ for (const name of [...moved].reverse()) {
300
+ try {
301
+ renameImpl(join(bundle.abs, name), join(topicDir, name));
302
+ rolledBack.push(name);
303
+ }
304
+ catch {
305
+ rollbackFailed.push(name);
306
+ }
307
+ }
308
+ // R-3:回滚干净时把本次创建的回收站目录 best-effort 清掉(与 clearTopic 的做法对齐),
309
+ // 不给用户留一个空的「回收站记录」;清不掉(非空/权限等)也不影响错误上报。
310
+ if (rollbackFailed.length === 0) {
311
+ try {
312
+ rmdirSync(bundle.abs);
313
+ }
314
+ catch { /* 非空或权限问题:留着无害 */ }
315
+ }
316
+ const inTrash = files.filter((x) => rollbackFailed.includes(x.name)).map((x) => x.name);
317
+ const remaining = files.filter((x) => !inTrash.includes(x.name)).map((x) => x.name);
318
+ const failure = {
319
+ ...(busy ? { busy: f.name } : {}),
320
+ moved: inTrash,
321
+ remaining,
322
+ rolledBack,
323
+ rollbackFailed,
324
+ // F-G:只给相对路径——错误响应永不带服务端绝对路径
325
+ trashRel: bundle.rel,
326
+ cause,
327
+ };
328
+ const tail = rollbackFailed.length === 0
329
+ ? '本次操作已全部撤销,文献库未改变'
330
+ : `但 ${rollbackFailed.length} 个文件未能回滚(仍在回收站,可手动恢复):${rollbackFailed.join('、')}`;
331
+ if (busy) {
332
+ throw new ManageError('file-busy', 409, `文件被占用(可能正在阅读器或其他程序中打开),关闭后重试;${tail}`, failure);
333
+ }
334
+ throw new ManageError('trash-move-failed', 500, `移入回收站失败;${tail}`, failure);
335
+ }
336
+ }
337
+ return { trashRel: bundle.rel, trashDir: bundle.abs, moved };
338
+ }
339
+ export function planPaperDelete(dataDir, topic, name) {
340
+ // 词法层统一走**边界类**(PR-1 的既定分层,t4 评审 [2] 的裁定):删除/rename-from 只挡
341
+ // 空/分隔符/`..`/NUL/保留名 `.trash`,**不**挡命名规范(前导点、超长、控制字符)。
342
+ // 理由(实测为真):listPapers 不过滤隐藏文件 → 手工放进库的 `.hidden.pdf` 会出现在列表里
343
+ // 却删不掉;pdfqa 旧库与手工整理都会产生这类条目。命名规范只在**创建 / 改名-to** 时强制
344
+ // (validateName / validateNewPaperName),即「能创建的必然能删除」。
345
+ // realpath 层由 resolveExistingTopicDir / assertArtifactsContained 兜底(与词法层无关)。
346
+ const paperName = validateEntryBoundary(name, 'paper');
347
+ const topicName = validateEntryBoundary(topic, 'topic');
348
+ const topicDir = resolveExistingTopicDir(dataDir, topicName);
349
+ const files = listArtifacts(topicDir, paperName);
350
+ assertArtifactsContained(dataDir, topicDir, files);
351
+ // `.pdf` 必须是**常规文件**:符号链接不是文献(listPapers 只看常规文件,故 resolvePaper 找不到它,
352
+ // 以前会以「文献库中找不到」的普通 Error 冒泡成 500)。这里判 404,与「合法名 + 不存在文献」一致。
353
+ if (!files.some((f) => f.kind === 'pdf' && !f.symlink)) {
354
+ throw new ManageError('not-found', 404, `文献不存在:${paperName}.pdf`);
355
+ }
356
+ return {
357
+ topic: topicName,
358
+ name: paperName,
359
+ files,
360
+ totalBytes: files.reduce((n, f) => n + f.bytes, 0),
361
+ trashRoot: '.trash',
362
+ notDeleted: [
363
+ // 明确「会话历史不删、学习档案文件会随文献进回收站」——原文案只说「会话历史不删」,
364
+ // 用户会以为学习进度(<文献>.study.json)也留着;实际它是派生产物,和 .txt/译文一样被移走(可恢复)。
365
+ '伴读会话历史(保存在 dsh 会话库里,不随文献删除;学习档案文件 <文献>.study.json 随文献移入回收站,可在回收站恢复)',
366
+ '共享翻译环境(.venv-pdf2zh)与翻译草稿目录(.pdf2zh-tmp)',
367
+ '专题目录本身(如需删除专题请另行操作)',
368
+ ],
369
+ };
370
+ }
371
+ export function deletePaper(dataDir, topic, name, renameImpl) {
372
+ const plan = planPaperDelete(dataDir, topic, name);
373
+ const topicDir = resolveExistingTopicDir(dataDir, plan.topic);
374
+ const bundle = newTrashBundle(dataDir);
375
+ const res = moveToTrash(bundle, topicDir, plan.files, renameImpl);
376
+ writeManifest(bundle.abs, {
377
+ v: 1,
378
+ kind: 'paper',
379
+ topic: plan.topic,
380
+ name: plan.name,
381
+ deletedAt: new Date().toISOString(),
382
+ files: plan.files.map((f) => ({ name: f.name, kind: f.kind, bytes: f.bytes, symlink: f.symlink })),
383
+ });
384
+ return {
385
+ ok: true,
386
+ topic: plan.topic,
387
+ name: plan.name,
388
+ moved: res.moved,
389
+ skipped: plan.files.filter((f) => !res.moved.includes(f.name)),
390
+ trashDir: res.trashDir,
391
+ trashRel: res.trashRel,
392
+ };
393
+ }
394
+ function writeManifest(bundleAbs, manifest) {
395
+ try {
396
+ writeFileSync(join(bundleAbs, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
397
+ }
398
+ catch {
399
+ // manifest 只是给人看的恢复线索;写不了不影响删除结果
400
+ }
401
+ }
402
+ export function planTopicDelete(dataDir, name) {
403
+ // 删除专题同样只做边界类校验:隐藏名专题(历史遗留)必须能删,否则是删不掉的孤儿。
404
+ const topic = validateEntryBoundary(name, 'topic');
405
+ const topicDir = resolveExistingTopicDir(dataDir, topic);
406
+ const all = readdirSync(topicDir, { withFileTypes: true });
407
+ const visible = all.filter((e) => !e.name.startsWith('.'));
408
+ const papers = visible.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.pdf')).map((e) => e.name.slice(0, -4));
409
+ return {
410
+ name: topic,
411
+ entries: visible.map((e) => e.name),
412
+ papers,
413
+ hidden: all.filter((e) => e.name.startsWith('.')).map((e) => e.name),
414
+ };
415
+ }
416
+ export function deleteTopic(dataDir, name, renameImpl) {
417
+ const plan = planTopicDelete(dataDir, name);
418
+ if (plan.entries.length > 0) {
419
+ throw new ManageError('topic-not-empty', 409, `专题内还有 ${plan.entries.length} 个条目(${plan.papers.length} 篇文献),请先逐篇删除`, {
420
+ entries: plan.entries,
421
+ papers: plan.papers,
422
+ });
423
+ }
424
+ const topicDir = resolveExistingTopicDir(dataDir, plan.name);
425
+ let movedHidden = [];
426
+ let trashDir = null;
427
+ let trashRel = null;
428
+ if (plan.hidden.length > 0) {
429
+ // 隐藏条目(.DS_Store 等)不阻塞删除,但也绝不静默销毁 → 进回收站后再 rmdir
430
+ const files = plan.hidden.map((h) => {
431
+ let bytes = 0;
432
+ let symlink = false;
433
+ try {
434
+ const st = lstatSync(join(topicDir, h));
435
+ bytes = st.size;
436
+ symlink = st.isSymbolicLink();
437
+ }
438
+ catch { /* 竞态:已消失 */ }
439
+ return { name: h, kind: 'tmp', bytes, symlink };
440
+ });
441
+ const bundle = newTrashBundle(dataDir);
442
+ const res = moveToTrash(bundle, topicDir, files, renameImpl);
443
+ movedHidden = res.moved;
444
+ trashDir = res.trashDir;
445
+ trashRel = res.trashRel;
446
+ writeManifest(bundle.abs, { v: 1, kind: 'topic-hidden', topic: plan.name, deletedAt: new Date().toISOString(), files });
447
+ }
448
+ try {
449
+ rmdirSync(topicDir); // 非递归:非空即失败,绝不 rm -rf
450
+ }
451
+ catch (err) {
452
+ const code = err.code;
453
+ // 只有「目录非空」才是 topic-not-empty;EACCES/EBUSY 等 errno 如实上交,
454
+ // 由 host 的错误通道给固定文案(上游 R1 ②:errno 不回显绝对路径、不谎报原因)。
455
+ if (typeof code === 'string' && code !== 'ENOTEMPTY' && code !== 'EEXIST')
456
+ throw err;
457
+ throw new ManageError('topic-not-empty', 409, '专题目录仍非空,未删除', {
458
+ entries: readdirSync(topicDir),
459
+ movedHidden,
460
+ // F-G:错误响应不带绝对路径(绝对 trashDir 只留给 200 成功响应)
461
+ trashRel,
462
+ cause: err.code ?? 'unknown',
463
+ });
464
+ }
465
+ return { ok: true, name: plan.name, movedHidden, trashDir, trashRel };
466
+ }
467
+ /** 清空预览(dry-run,只读):逐篇列出将移入回收站的产物;不写盘。 */
468
+ export function planTopicClear(dataDir, topic) {
469
+ const topicName = validateEntryBoundary(topic, 'topic');
470
+ const topicDir = resolveExistingTopicDir(dataDir, topicName);
471
+ const papers = [];
472
+ for (const p of listPapers(dataDir, topicName)) {
473
+ const files = listArtifacts(topicDir, p.name);
474
+ assertArtifactsContained(dataDir, topicDir, files);
475
+ // 与 planPaperDelete 同口径:`.pdf` 必须是常规文件(符号链接不是文献)
476
+ if (!files.some((f) => f.kind === 'pdf' && !f.symlink))
477
+ continue;
478
+ papers.push({ name: p.name, files, totalBytes: files.reduce((n, f) => n + f.bytes, 0) });
479
+ }
480
+ const all = readdirSync(topicDir, { withFileTypes: true });
481
+ return {
482
+ topic: topicName,
483
+ papers,
484
+ papersCount: papers.length,
485
+ filesCount: papers.reduce((n, p) => n + p.files.length, 0),
486
+ totalBytes: papers.reduce((n, p) => n + p.totalBytes, 0),
487
+ hidden: all.filter((e) => e.name.startsWith('.')).map((e) => e.name),
488
+ trashRoot: TRASH_DIR,
489
+ notDeleted: [
490
+ '伴读会话历史(保存在 dsh 会话库里,不随文献清空;学习档案文件 <文献>.study.json 属派生产物,随文献移入回收站,可在回收站恢复)',
491
+ '共享翻译环境(.venv-pdf2zh)与翻译草稿目录(.pdf2zh-tmp)',
492
+ '库外文件(清空只作用于该专题目录内)',
493
+ ],
494
+ };
495
+ }
496
+ /**
497
+ * 清空专题:**一次**回收站 bundle,每篇一个子目录 `<bundle>/<篇名>/<文件>`(避免同名产物跨篇撞名),
498
+ * 每篇一份 manifest + bundle 根一份总清单;全部移完后把隐藏条目移入 bundle 根,最后**非递归**
499
+ * `rmdir` 专题目录(绝无 rm -rf)。
500
+ *
501
+ * 原子性口径(**不是文件系统事务,是「失败即回滚」**):
502
+ * ① 任何时候都不会 unlink 用户数据(只有我们自己的 manifest 会被清掉);
503
+ * ② 任一步失败 → 先把**本次请求已移动的全部文件**(含已完成篇)rename 回原位(best-effort);
504
+ * ③ 回滚失败的文件如实列进 `rollbackFailed`,且**一定**在回收站里(给 trashDir/trashRel 供手动恢复)。
505
+ */
506
+ export function clearTopic(dataDir, topic, renameImpl = renameSync) {
507
+ const plan = planTopicClear(dataDir, topic);
508
+ // 空专题不属于「清空」的职责(删除专题才是)——把两件事分开,避免留下一个空目录
509
+ if (plan.papersCount === 0) {
510
+ throw new ManageError('bad-request', 400, '专题已为空,请用删除专题', { clearEndpoint: '/api/library/delete-topic' });
511
+ }
512
+ const topicDir = resolveExistingTopicDir(dataDir, plan.topic);
513
+ const bundle = newTrashBundle(dataDir);
514
+ const done = [];
515
+ const stamp = new Date().toISOString();
516
+ let failedPaper = '';
517
+ try {
518
+ for (const p of plan.papers) {
519
+ failedPaper = p.name;
520
+ const sub = { abs: join(bundle.abs, p.name), rel: join(bundle.rel, p.name) };
521
+ mkdirSync(sub.abs, { recursive: true, mode: 0o700 });
522
+ const res = moveToTrash(sub, topicDir, p.files, renameImpl);
523
+ done.push({ paper: p.name, sub, moved: res.moved });
524
+ writeManifest(sub.abs, {
525
+ v: 1, kind: 'paper', topic: plan.topic, name: p.name, deletedAt: stamp,
526
+ files: p.files.map((f) => ({ name: f.name, kind: f.kind, bytes: f.bytes, symlink: f.symlink })),
527
+ });
528
+ }
529
+ // 隐藏条目:全部文献移完后进 bundle 根(隐藏名以 `.` 开头,不会与篇名子目录撞名)
530
+ let movedHidden = [];
531
+ if (plan.hidden.length > 0) {
532
+ failedPaper = `.hidden`;
533
+ const files = plan.hidden.map((h) => {
534
+ let bytes = 0;
535
+ let symlink = false;
536
+ try {
537
+ const st = lstatSync(join(topicDir, h));
538
+ bytes = st.size;
539
+ symlink = st.isSymbolicLink();
540
+ }
541
+ catch { /* 竞态:已消失 */ }
542
+ return { name: h, kind: 'tmp', bytes, symlink };
543
+ });
544
+ movedHidden = moveToTrash(bundle, topicDir, files, renameImpl).moved;
545
+ }
546
+ writeManifest(bundle.abs, {
547
+ v: 1, kind: 'topic-clear', topic: plan.topic, deletedAt: stamp,
548
+ papers: plan.papers.map((p) => ({ name: p.name, files: p.files.map((f) => f.name) })),
549
+ hidden: plan.hidden,
550
+ });
551
+ failedPaper = '';
552
+ try {
553
+ rmdirSync(topicDir); // 非递归:非空即失败,绝不 rm -rf
554
+ }
555
+ catch (err) {
556
+ const code = err.code;
557
+ if (typeof code === 'string' && code !== 'ENOTEMPTY' && code !== 'EEXIST')
558
+ throw err;
559
+ throw new ManageError('topic-not-empty', 409, '专题目录仍非空,未删除', {
560
+ entries: readdirSync(topicDir), cause: code,
561
+ });
562
+ }
563
+ return {
564
+ ok: true, topic: plan.topic, cleared: done.length,
565
+ movedFiles: done.reduce((n, d) => n + d.moved.length, 0),
566
+ movedHidden, trashDir: bundle.abs, trashRel: bundle.rel,
567
+ };
568
+ }
569
+ catch (err) {
570
+ // ── 失败即回滚:把本次请求**已移动的全部文件**rename 回原位(含已完成篇)──
571
+ const base = isManageError(err) ? err.extra : {};
572
+ const rolledBack = [];
573
+ const rollbackFailed = [];
574
+ for (const d of [...done].reverse()) {
575
+ for (const name of [...d.moved].reverse()) {
576
+ try {
577
+ renameImpl(join(d.sub.abs, name), join(topicDir, name));
578
+ rolledBack.push(`${d.paper}/${name}`);
579
+ }
580
+ catch {
581
+ rollbackFailed.push(`${d.paper}/${name}`);
582
+ }
583
+ }
584
+ }
585
+ // 回滚干净 → 把本次创建的回收站子目录/根目录(此时只剩我们自己的 manifest)清掉,
586
+ // 不给用户留一个空的「回收站记录」;清不掉也不影响结果(best-effort,不覆盖上面的错误)。
587
+ if (rollbackFailed.length === 0) {
588
+ for (const d of done) {
589
+ try {
590
+ rmSync(join(d.sub.abs, 'manifest.json'), { force: true });
591
+ rmdirSync(d.sub.abs);
592
+ }
593
+ catch { /* 留着无害 */ }
594
+ }
595
+ try {
596
+ rmSync(join(bundle.abs, 'manifest.json'), { force: true });
597
+ rmdirSync(bundle.abs);
598
+ }
599
+ catch { /* 留着无害 */ }
600
+ }
601
+ const prevRolledBack = Array.isArray(base.rolledBack) ? base.rolledBack : [];
602
+ const prevFailed = Array.isArray(base.rollbackFailed) ? base.rollbackFailed : [];
603
+ const failedPaperRolledBack = prevRolledBack.map((n) => `${failedPaper || '?'}/${n}`);
604
+ const failedPaperFailed = prevFailed.map((n) => `${failedPaper || '?'}/${n}`);
605
+ const allRollbackFailed = [...failedPaperFailed, ...rollbackFailed];
606
+ const totalProcessed = done.length + (failedPaper ? 1 : 0);
607
+ const totalRolledBack = failedPaperRolledBack.length + rolledBack.length;
608
+ const tail = allRollbackFailed.length === 0
609
+ ? `已处理 ${totalProcessed} 篇:本次已移动的文件已全部回滚,专题内容未改变`
610
+ : `已处理 ${totalProcessed} 篇:${totalRolledBack} 个文件已回滚,但 ${allRollbackFailed.length} 个文件未能回滚(仍在回收站,可手动恢复):${allRollbackFailed.join('、')}`;
611
+ // F-C:**有文件没回滚时绝不保留内层那句「…已全部撤销,文献库未改变」**——它只对失败的那一篇成立,
612
+ // 拼到整体回滚结果上就自相矛盾。内层只保留「;」之前的可执行前缀(如「文件被占用…关闭后重试」),
613
+ // 回滚结论一律由外层的 tail 单方面给出。
614
+ const innerRaw = isManageError(err) ? err.message : '清空中断';
615
+ const inner = allRollbackFailed.length === 0 ? innerRaw : (innerRaw.split(';')[0] || innerRaw);
616
+ const message = `${inner}|${tail}`;
617
+ // F-H:字段名要说清语义——`unprocessedPapers` 只列「这次压根没轮到」的篇;
618
+ // 「用户数据还在回收站」的篇另给 `papersWithFilesInTrash`(回滚失败时唯一能自救的清单)。
619
+ const unprocessedPapers = plan.papers
620
+ .map((p) => p.name)
621
+ .filter((n) => !done.some((d) => d.paper === n) && n !== failedPaper);
622
+ const papersWithFilesInTrash = [...new Set(allRollbackFailed.map((n) => n.split('/')[0]))];
623
+ throw new ManageError(isManageError(err) ? err.code : 'trash-move-failed', isManageError(err) ? err.status : 500, message, {
624
+ ...base,
625
+ // `cleared` = 本次**真正清空**(最终仍留在回收站)的篇数:失败即回滚 ⇒ 恒为 0
626
+ cleared: 0,
627
+ clearedBeforeRollback: done.map((d) => d.paper),
628
+ failedPaper: failedPaper || null,
629
+ rolledBack: [...failedPaperRolledBack, ...rolledBack],
630
+ rollbackFailed: allRollbackFailed,
631
+ moved: allRollbackFailed,
632
+ unprocessedPapers,
633
+ papersWithFilesInTrash,
634
+ // F-G:错误响应只给相对路径(绝对 trashDir 只留在 200 成功响应)
635
+ trashRel: bundle.rel,
636
+ });
637
+ }
638
+ }
639
+ /**
640
+ * 产物改名:**纯前缀拼接**,不能用 `String.replace(paperName, newName)`——
641
+ * `newName` 里的 `$&` / `` $` `` / `$'` / `$1` 会被当成**替换模式**展开(t3-F2):
642
+ * 实测 10 个产物会得到各不相同的 stem、产生无法再按名删除的孤儿文件,且响应里的 `to`
643
+ * 与磁盘不符。`listArtifacts` 保证 `f.name` 一定以 `paperName` 开头(精确 stem 匹配)。
644
+ */
645
+ export function renamedName(fileName, paperName, newName) {
646
+ return fileName.startsWith(paperName) ? newName + fileName.slice(paperName.length) : fileName;
647
+ }
648
+ /** 重命名文献:先校验全部目标,再逐个 rename;中途失败 best-effort 回滚。 */
649
+ export function renamePaper(dataDir, topic, name, newNameRaw, renameImpl = renameSync) {
650
+ // rename-from(topic/name)只做边界类校验;改名**目标**名仍走命名规范类(validateNewPaperName)。
651
+ const topicName = validateEntryBoundary(topic, 'topic');
652
+ const paperName = validateEntryBoundary(name, 'paper');
653
+ const topicDir = resolveExistingTopicDir(dataDir, topicName);
654
+ const newName = validateNewPaperName(newNameRaw, paperName);
655
+ const files = listArtifacts(topicDir, paperName);
656
+ assertArtifactsContained(dataDir, topicDir, files);
657
+ if (!files.some((f) => f.kind === 'pdf'))
658
+ throw new ManageError('not-found', 404, `文献不存在:${paperName}.pdf`);
659
+ // 目标名冲突预检(大小写-only 改名放行:同一文件)
660
+ const clash = [];
661
+ for (const f of files) {
662
+ const target = join(topicDir, renamedName(f.name, paperName, newName));
663
+ if (!existsSync(target))
664
+ continue;
665
+ let same = false;
666
+ try {
667
+ same = realpathSync(target) === realpathSync(join(topicDir, f.name));
668
+ }
669
+ catch { /* 目标不可读 → 视为冲突 */ }
670
+ if (!same)
671
+ clash.push(basename(target));
672
+ }
673
+ if (clash.length > 0) {
674
+ throw new ManageError('target-exists', 409, `已存在同名文件:${clash.slice(0, 3).join(', ')}${clash.length > 3 ? ' …' : ''}`, { conflicts: clash });
675
+ }
676
+ const moved = [];
677
+ for (const f of files) {
678
+ const from = join(topicDir, f.name);
679
+ const to = join(topicDir, renamedName(f.name, paperName, newName));
680
+ if (from === to)
681
+ continue;
682
+ try {
683
+ renameImpl(from, to);
684
+ moved.push({ from, to });
685
+ }
686
+ catch (err) {
687
+ const code = err.code;
688
+ const busy = code === 'EBUSY' || code === 'EPERM';
689
+ // 反向回滚(rename 回原名,绝不 unlink);失败的文件如实记账,绝不静默
690
+ const rolledBack = [];
691
+ const rollbackFailed = [];
692
+ for (const m of moved.reverse()) {
693
+ try {
694
+ renameImpl(m.to, m.from);
695
+ rolledBack.push(basename(m.from));
696
+ }
697
+ catch {
698
+ rollbackFailed.push(basename(m.from));
699
+ }
700
+ }
701
+ const remaining = files.filter((f2) => !rollbackFailed.includes(f2.name)).map((f2) => f2.name);
702
+ const tail = rollbackFailed.length === 0
703
+ ? '本次改名已全部撤销,文献库未改变'
704
+ : `但 ${rollbackFailed.length} 个文件未能回滚(仍在改名后的名字上,请手动检查):${rollbackFailed.join('、')}`;
705
+ // EBUSY/EPERM(Windows 文件占用)→ 409 file-busy 可执行文案(上游必修 3 的语义)
706
+ if (busy) {
707
+ throw new ManageError('file-busy', 409, `文件被占用(可能正在阅读器或其他程序中打开),关闭后重试;${tail}`, {
708
+ busy: basename(from), moved: [], remaining, rolledBack, rollbackFailed, cause: code,
709
+ });
710
+ }
711
+ // 其它 errno:回滚干净时原样上报(host 的错误通道给固定中文文案 400,不回显绝对路径)
712
+ if (typeof code === 'string' && code && rollbackFailed.length === 0)
713
+ throw err;
714
+ throw new ManageError('rename-partial', 500, `重命名中途失败;${tail}`, {
715
+ moved: [], remaining, rolledBack, rollbackFailed, cause: code ?? 'unknown',
716
+ });
717
+ }
718
+ }
719
+ return { ok: true, moved: moved.map((m) => basename(m.to)), from: paperName, to: newName };
720
+ }
721
+ /** 重命名专题:目录整体 rename(专题为空时才允许?——不:目录改名与内容无关,非空也可改)。 */
722
+ export function renameTopic(dataDir, name, newNameRaw, renameImpl = renameSync) {
723
+ // from 侧是已存在目录 → 边界类(历史遗留名也能改名);to 侧是新名字 → 命名规范类。
724
+ const topic = validateEntryBoundary(name, 'topic');
725
+ const newTopic = validateName(newNameRaw, 'topic');
726
+ if (newTopic === topic)
727
+ throw new ManageError('bad-request', 400, '新名称与原名称相同');
728
+ const dir = resolveExistingTopicDir(dataDir, topic);
729
+ const root = realDataDir(dataDir);
730
+ const target = join(root, newTopic);
731
+ if (existsSync(target))
732
+ throw new ManageError('target-exists', 409, `已存在同名专题:${newTopic}`);
733
+ assertInside(root, target);
734
+ try {
735
+ renameImpl(dir, target);
736
+ }
737
+ catch (err) {
738
+ const code = err.code;
739
+ // Windows 文件占用(EBUSY/EPERM)→ 409 file-busy 可执行文案(上游必修 3)。
740
+ if (code === 'EBUSY' || code === 'EPERM') {
741
+ throw new ManageError('file-busy', 409, '文件被占用(可能正在阅读器或其他程序中打开),关闭后重试;本次改名未生效', {
742
+ busy: newTopic, cause: code,
743
+ });
744
+ }
745
+ // 其它 errno(ENAMETOOLONG/EACCES…)带上原始 .code 上交:host 的错误通道给固定中文文案 400,
746
+ // 绝不把绝对路径回显给客户端。非 errno 才归为 rename-partial。
747
+ if (typeof code === 'string' && code)
748
+ throw err;
749
+ throw new ManageError('rename-partial', 500, '重命名专题失败', {});
750
+ }
751
+ return { ok: true, moved: [], from: topic, to: newTopic };
752
+ }