novel_delete_book
按书删除(v6.8.0 新增,第 21 个工具)。一本书的资料散在 13 个主位置——章节正文、创作资料、数据目录下的 10 个按书子目录(含 v7.3.0 的 drafts 草稿镜像),以及 state.json 里的设定库条目;此外还有 v0.8.0 之前写在数据目录根上的旧位置伏笔表,单独计入 legacy。本工具一次列出全部待清理位置、文件数、体积与样例文件名,默认只预览;执行时把内容移入回收站(可恢复),需要真省空间时才用 purge 彻底删除。
起因是 issue #2:用户放弃一本书、删测试项目,或用同名重开时发现删不干净——只删 novels/<书>/,其余关联资料会留在多处,而面板里的「删除设定」只是移除 创作资料.md 里的一段,并不是删书。用户只能自己翻多个目录逐项清理,容易漏;多本书并存时还怕误删别的书。
因此本工具刻意不按目录猜测(那会漏掉按书命名的散落文件),也不按「文件名包含书名」粗暴匹配(那会误伤《孤舟子》):它用白名单位置 + 前缀与分隔符边界两件事同时约束「什么算这本书的资料」,并把删除做成先预览、再移入回收站、可原样恢复的三段式。
rename 到 <数据目录>/trash/<书>-<时间戳>/,字节仍在盘上;面板「数据目录」的体积统计包含 trash/,所以删书之后体积会变大而不是变小。要真正释放空间请用 action: "purge";trash/ 不会自动清理。
参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
book | string | preview / apply 必填 | 书名,应与 novels/ 下的目录名一致。取值先 trim,再经 sanitizeSegment 清洗;盘上目录名与清洗名两种写法都能定位到同一本书(先按原名精确找,再按清洗名等价匹配)。list / restore / purge 不需要它。 |
action | string | 否 | preview(默认,只算不写盘)/ apply(执行删除,移入回收站)/ list(列出回收站条目)/ restore(从回收站恢复,需 trashId)/ purge(彻底删除某个回收站条目,需 trashId,不可恢复)。五个合法取值由参数 schema 的 enum 约束;执行链路对枚举外的值一律按 preview 处理。 |
trashId | string | restore / purge 必填 | 回收站条目 id,取自 apply 返回的 trashId 或 action: "list" 的条目 id。只接受单段名:首尾空格会先被去掉(requiredString 的常规 trim),随后含路径分隔符(/、\)、含 ..、以点号开头、或为空则报错;校验通过后还会确认解析结果确实落在回收站目录内。 |
root | string | 否 | 章节库根目录(含 novels 子目录)。省略时按插件既有的根解析顺序回退(面板路由走 config.root → lastRoot 同一套口径,不另起一套)。 |
keys | array<string> | 否 | 只清理这些位置,元素取自 preview 返回的 targets[].key(如 ["data:audits","data:style-reports","data:analysis"] 就是「只删报告历史与缓存,正文留着」)。省略 = 全选 = 删整本书(既有行为)。只在 apply 时生效;含未知 key、空数组、非数组都会报错 |
参数 schema 声明 additionalProperties: false,只接受以上五项。工具与面板路由共用同一条执行链路(deleteBookDispatch),因此面板上看到的清单与 AI 调工具得到的清单必然同源。与 novel_diagnose 不同,本工具会调用 assertToolEnabled:被关掉时按普通工具处理(抛错并提示去 UI 开启)。
输出结构
输出 schema 为 additionalProperties: false,下列字段每个 action 都常驻(无内容的字段给空数组 / 零值 / 空串),避免调用方按 action 拿不到键:action、book、safeBook、found、blocked、blockedReason、targets、totals、warnings、notTouched、trash、trashId、trashDir、moved、keys、skipped、partial、profileRemoved、profileRestored、restored、conflicts、failed、remaining、purged、message。
| 字段 | 类型 | 内容 |
|---|---|---|
action | string | 本次生效的 action(默认 preview) |
book / safeBook | string | book 是定位到的盘上目录名(也就是返回体里给人看的书名);safeBook 是清洗后的写法,回收站条目名以它为前缀 |
found | boolean | 是否找到这本书的任何内容(正文目录、创作资料与数据目录里的按书文件任一存在)。list 时为「回收站非空」 |
blocked / blockedReason | boolean / string | 书名命中保留名(_global、创作资料、点号开头)时为 true 并给出中文原因;preview 只标记不抛错,apply 直接报错 |
targets | array | 待清理位置清单,一处一行。每项为 { key, label, kind, path, rel, exists, files, bytes, sample[] };kind ∈ dir(整目录移走)/ files(只移走匹配文件,目录留着)/ state(只从 state.json 摘掉本书条目) |
targets[].sample | array | 样例文件名,最多 4 个;用于在确认弹窗里让用户认得出「这些确实是我的文件」,不是完整清单 |
totals | object | { files, bytes }:只合计本次选中的位置(传 keys 时即所选那几处;省略 keys 时等于全部 targets);purge 时为被删条目的合计。不是按全量 targets 累加——只清缓存时不会把正文体积累加进来 |
warnings | array | 需要留意的提示:同名清洗冲突、书名被清洗、只有关联资料没有正文、设定库条目名与参数不同、清单写入失败、逐项失败、清理后仍有残留等 |
notTouched | array | 明确不受影响的东西(全局词表 / 插件通用设置 / 事件日志 / 其他书的正文与资料),逐条写成中文 |
trash | array | 仅 list 时有内容:条目为 { id, book, createdAt, files, bytes, items, restorable, path },按 createdAt 新→旧排序 |
trash[].restorable | boolean | 是否有可用的 manifest.json。没有清单的条目(清单写入失败 / 手动放进回收站)仍会列出,但不能一键恢复 |
trashId / trashDir | string | 本次生成的回收站条目 id 与绝对路径。一项都没搬动且设定库未移除时会删掉空目录,两者均为空串 |
moved | integer | 实际移走的项数(目录项按 1 项计,文件项按文件计);不等于 totals.files |
profileRemoved / profileRestored | boolean | apply 时是否同时移除了设定库里的本书条目;restore 时是否恢复了它 |
restored | integer | restore 成功恢复的文件数:整目录恢复也按目录内实际文件数计(空目录按 1 计);目录已被重建而逐文件合并时,按实际移回的文件数计 |
conflicts | array | restore 时原位置已有同名内容而跳过的路径(含设定库条目名)。绝不覆盖,也不做合并改写 |
failed | array | 逐项失败:{ path, code, error }。error 为「code + 截断到 160 字的错误消息」;某一项失败不中断其余项 |
remaining | object | apply 后再跑一次预览得到的残留 { files, bytes }——被占用 / 无权限的文件会留在这里,如实报告 |
purged | boolean | purge 是否成功;成功即不可恢复 |
message | string | 一句话结论,例如「预览:《孤舟》共 14 个文件待清理(磁盘未改动)」「已将《孤舟》的 5 项(14 个文件 / 204800 字节)移入回收站,并移除设定库条目」 |
渲染层(output.render)返回 [{ type: "text", text: formatBookDelete(value) }]:一份固定格式的纯文本,首行是 【按书删除 · 预览】《书名》 或 【按书删除 · 已执行】《书名》,随后逐行列出将清理 / 已清理的位置(标签 · 文件数 · 体积 · 相对路径 · 样例),再接「不会动」一句与 warnings。执行后另附回收站路径、trashId(含可照抄的 action=restore trashId=… 写法)、残留文件数与失败项。预览末尾固定写「以上为预览,磁盘上还没有任何改动」。
行为与实现
1 · 五个 action
| action | 必填 | 行为 | 写盘 |
|---|---|---|---|
preview | book | 计算清单:13 个主位置 + 旧位置逐个判断存在性、文件数、体积与样例,并根据书名产出 blocked / warnings / notTouched | 否 |
apply | book | 先算一遍清单(与预览同一套 planBookDelete),按 keys(省略=全选)决定处理哪些位置,再逐项移入回收站、摘掉设定库条目、写 manifest.json,最后再算一遍残留 | 是 |
list | — | 读取 trash/,按清单或目录时间倒序列出条目;无清单的条目标 restorable:false | 否 |
restore | trashId | 按清单把内容移回原路径;目标已存在则逐文件跳过并记入 conflicts;全部恢复完才删除回收站条目 | 是 |
purge | trashId | 彻底删除一个回收站条目(rm -rf),删后仍存在则报错(多半被占用) | 是,不可恢复 |
apply 的两道硬前置:书名被拒绝(blocked)直接报错;一处内容都没找到(found:false)也报错,并提示先用 novel_books 确认书名——不为一次空删除造一个空回收站条目。
2 · 一本书的资料散在 13 个主位置(外加一个旧位置)
「13 个主位置」的构成:① 章节正文、② 创作资料、③–⑫ 数据目录下的 10 个按书子目录(9 个扁平文件目录 + 单独的 drafts/ 目录)、⑬ 设定库条目。旧位置伏笔表不占这 13 个之一,单独作为 legacy 计入清单。
| # | key | 位置 | kind | 命名范式 / 说明 |
|---|---|---|---|---|
| ① | chapters | 章节正文 novels/<书>/ | dir | 整个作品目录(章节目录),按书名(盘上真实目录名)定位 |
| ② | creation | 创作资料 novels/创作资料/<书>/ | dir | 创作设定 / 主要人物 / 次要人物 / 剧情大纲 / 钩子记录 / 创作状态卡 |
| ③ | data:analysis | 分析缓存 .novel-writer/analysis/ | files | <书>-chapters-metrics.json、<书>-full.json、<书>-<hash16>.json、<书>-keywords-<hash12>.json、<书>__base-full.json |
| ④ | data:audits | 改稿 / 审计报告 .novel-writer/audits/ | files | <书>-<hash16>.json,以及改稿台的 fix-plan-<书>-第01章.json(前缀是 fix-plan-) |
| ⑤ | data:baseline | 风格基线 .novel-writer/baseline/ | files | <书>.json |
| ⑥ | data:embedding | 语义索引缓存 .novel-writer/embedding/ | files | <书>.json、<书>__styleproto.json、<书>__style_2.json、<书>__style_第02章.md.json |
| ⑦ | data:lexicon | 本书必用词表 .novel-writer/lexicon/ | files | <书>.json;同目录的 _global.json 是全局词表,永远不动 |
| ⑧ | data:plots | 伏笔登记 .novel-writer/plots/ | files | <书>.json |
| ⑨ | data:settings | 设定表 .novel-writer/settings/ | files | <书>.json |
| ⑩ | data:style-reports | 风格画像 .novel-writer/style-reports/ | files | <书>.json |
| ⑪ | data:summaries | 章节摘要 .novel-writer/summaries/ | files | <书>.json |
| ⑫ | data:drafts | 草稿镜像 .novel-writer/drafts/<书>/ | dir | v7.3.0 新增:实时聊天写作的逐段镜像,按书一个目录,整目录移入回收站(不放进扁平文件白名单,见源码位置说明) |
| ⑬ | profile | 设定库条目 state.json 的 creationProfiles[<书>] | state | 不是文件;只在 state.json 里摘掉或写回这一本书的键,开关 / 容差 / 提示词档位 / 其他书条目一律保留 |
| 旧 | legacy | 旧位置伏笔表 .novel-writer/<书>.json | files | v0.8.0 之前伏笔表写在数据目录根上;现有书大多已迁到 plots/,但从未读过伏笔表的书可能还留着,漏掉就等于漏删 |
targets[].key 的取值就是上表的 key 列(数据目录 10 项为 data:<子目录>),label 为中文位置名(如「分析缓存」「草稿镜像」「旧位置伏笔表」「设定库条目(仅本书)」),rel 为相对书库根的路径,便于面板与 AI 直接引用。9 个扁平子目录走白名单(DELETE_DATA_SUBDIRS)而不是遍历数据目录:trash/ 与将来新增的目录不该被当成「书的资料」,漏登记会由测试盯住;drafts 则单独登记为 kind:"dir" 的整目录目标(它按书一个目录,放进扁平文件白名单会永远命中 0 个)。
只清其中几类(选择性清理):apply 传 keys 就只处理这几处,其余原样保留。最常见的用法是只清报告与缓存——keys: ["data:analysis","data:audits","data:style-reports"](分析缓存 / 改稿与审计报告 / 风格画像,也就是面板「报告历史」里的那些文件),正文、创作资料、伏笔、设定、摘要、词表全部留着。返回体的 keys 是本次实际处理的位置、skipped 是被保留的位置、partial:true 表示这是部分清理;此时 warnings 写的是「按选择保留」而不是「还有残留」——两者语义完全不同,别混读。面板里就是逐个勾选框 + 「全选 / 全不选」,默认全选(=删整本书)。
3 · 为什么需要边界匹配:不能只删 <书>.json
按书命名的文件不止一种写法。真实盘面上出现过:<书>-chapters-metrics.json、<书>-<hash16>.json、<书>-keywords-<hash12>.json、<书>__base-full.json、<书>__styleproto.json、<书>__style_2.json、<书>__style_第02章.md.json、audits 下的 <书>-<hash16>.json、改稿台的 fix-plan-<书>-第01章.json,以及旧位置的 <书>.json。所以「删掉 <书>.json」必然漏。
但反过来也不能用「文件名包含书名」或「以书名开头就算」:那样删《孤舟》会顺手删掉《孤舟子》的资料。匹配口径因此是前缀 + 分隔符边界:
<书>.json)
name 以 key 开头,且下一字符 ∈ { ".", "-", "_" } → 命中(.json / -hash / __style)
name 以 "fix-plan-" + key + "-" 开头 → 命中(改稿台报告,书名在中间)
其余 → 不命中
keys = 去重后的 [原始书名, novels/ 下真实目录名, sanitizeSegment 后的书名]
—— 插件各处落盘写法并不统一(audits 用原始 book,其余多用清洗名),
三种写法都要能定位,否则会出现「预览说没找到而书明明在」| 盘上文件名 | 属于《孤舟》? | 原因 |
|---|---|---|
孤舟.json | 是 | 完全相等 |
孤舟-abc1234567890def.json | 是 | 下一字符是 - |
孤舟__style_2.json | 是 | 下一字符是 _ |
fix-plan-孤舟-第01章.json | 是 | 命中 fix-plan- 前缀规则 |
孤舟子.json | 否 | 下一字符是「子」,不是分隔符 |
孤舟子-abc1234567890def.json | 否 | 同上,边界不成立 |
_global.json | 否 | 不以任何一本书名开头;全局词表永远不匹配 |
第二道保护是位置白名单:只有上述 13 个主位置 + 旧位置会被枚举,数据目录里的其他目录(含 trash/)根本不参与匹配。第三道是书名解析:先按用户给的原名找盘上目录,找不到再按清洗名等价匹配(例如书名里的非法字符落盘时被过滤),并在 warnings 里说明「实际匹配的是清洗后的写法」。
4 · 安全语义:回收站、manifest 与冲突跳过
restore 完全按清单逆操作,规则只有一条:绝不覆盖。
- 目标路径不存在:整项(目录则整目录)移回原路径。
- 目标目录已被重建(同名重开是最常见的场景):不能整块跳过,否则会出现「点了恢复,什么都没回来」。改为逐文件合并恢复:能回的都回,只有同名文件才跳过并记入
conflicts。 - 文件项的目标已存在:跳过并记入
conflicts,不做覆盖、不做合并改写。 - 设定库条目:若同名键已被新书占用则跳过(记入
conflicts),否则按备份原样写回。 - 收尾:全部恢复成功才删除回收站条目;仍有未恢复项时,把
manifest.json重写为「只含未恢复项」,下次可重试(已存在的项会再次被当冲突跳过,幂等)。
purge 是唯一不可恢复的 action:校验 trashId 为单段名、解析结果在回收站目录内,然后整个条目删除;删完仍存在就报错(多半被其他程序占用)。action: "list" 会把没有清单的目录也列出来(restorable:false),因为「清单写得失败但文件确实在回收站里」是已知情形——这类条目只能手动移回,不能一键恢复。
5 · 拒绝项
| 情形 | preview | apply |
|---|---|---|
书名为空(trim 后) | 报错「参数 book 不能为空字符串」 | 同左 |
清洗后等于 _global | blocked:true + 原因:「书名与全局词表文件同名」 | 直接报错,未做任何改动 |
原始或清洗后等于 创作资料 | blocked:true + 原因:「是插件的保留目录」 | 直接报错 |
以点号开头(.novel-writer 等) | blocked:true + 原因:「点号开头的目录是保留位置」 | 直接报错 |
. / .. | 在清洗阶段即被拒 | 同左 |
| 一处内容都找不到 | found:false + 提示先确认书名 | 报错「没找到《书》的任何内容」 |
trashId 非法 | — | 报错:只接受单段名(首尾空格先被 trim 掉,随后才校验);/、\、..、点号开头、空串一律拒绝 |
| 回收站条目没有清单 | list 标 restorable:false | restore 报错并给出可手动移回的目录路径 |
注意区分「拒绝」与「找不到」:拒绝是书名本身不允许删(可能误伤插件自己的文件),预览阶段就要让人看见原因;找不到是书名写错或这本书已经删过,apply 会拒绝执行,避免造一个空回收站条目。
6 · 不会动的东西
- 全局词表
.novel-writer/lexicon/_global.json:_global永不匹配(匹配用的书名集合里也不含它)。 state.json里的其他设置:开关、风格容差、系统提示词档位与场景、leanWorkflow、lastRoot、其他书的creationProfiles条目全部保留;本工具只摘掉本书那一个键。- 事件日志:在状态文件旁的
~/.dsh/dsh-novel-writer/logs/events.jsonl(受DSH_NOVEL_WRITER_STATE覆盖),不在书库根的.novel-writer/里,删书不会碰它。 - 其他书的正文与资料:只枚举所选书名的匹配项;其他书名不参与。
trash/里别的条目也只在purge指定trashId时才会被删。 - 数据目录中的非白名单内容:10 个按书子目录以外的东西(如
trash/、将来新增的目录)不参与匹配。
notTouched 会把上述内容逐条写进返回体,预览时就能看到「删除不会影响什么」——这不是装饰:删书是破坏性操作,用户最需要确认的恰是「别的书没事」。
7 · 面板入口(与工具同一实现)
首页第一个分区(写作资料)里的一行「删除整本书」是主入口,点进去是独立页面,顺序就是操作顺序:① 列出书库里的书(书名 / 章数 / 字数),每本右侧「删除整本书…」;② 选中后在同一页显示预览清单(位置 / 文件数 / 体积 / 样例文件名)与「不会动」说明,每处位置一个勾选框(默认全选,另有「全选 / 全不选」——取消正文与创作资料、只留报告类,就是面板上的「只清这一类」),勾选确认后才可点「移入回收站」;③ 执行后在同一页显示结果、失败项与回收站条目。页面下方常驻「回收站」小节:条目可恢复或彻底删除(后者需二次确认),不用再记「回收站在哪个分区里」。
「书库概况」折叠区里每本书旁也保留了一个「删除整本书…」快捷按钮(直接弹出同一份预览),但它是补充入口,不是唯一入口。列表外观与全站一致:复用同一个 PreferenceRow(图标 + 标题 + 副标题 + 行尾文字动作),行尾动作用 .nwRowAction 文字链而不是带边框的按钮。
面板按钮与 AI 调工具走同一条函数链路(deleteBookDispatch)与同一份根解析口径,不是面板另写一套预览逻辑——否则两边对「什么算这本书的文件」迟早会分叉。面板侧的入口与动作:页面 deletePick 选书、弹窗 deleteOpen 快捷入口,两者共用 loadDeletePlan(含陈旧响应守卫);执行 deleteApply,回收站 trashLoad / trashRestore / trashPurge;都经 deleteBookRoute 打到宿主路由。
8 · 宿主路由与错误码
keys 只在 apply 时生效)
成功 → 200 { ok: true, ...与工具完全一致的返回体 }| 情形 | 状态码 | 返回 |
|---|---|---|
非回环地址(且未开 allowLanState) | 403 | { error: "forbidden: loopback-only" } |
| 方法不是 GET / POST | 405 | { error: "method not allowed: <方法>" } |
| POST 体不是 JSON 对象(空 / 数组 / 标量) | 400 | { error: "invalid JSON body" } |
参数或业务错误(书名被拒、找不到、trashId 非法等) | 400 | { ok: false, error: "<前 240 字>" } |
写盘失败(ENOVELWRITEFAIL) | 500 | { ok: false, error: "…" } |
| 成功 | 200 | { ok: true, action, book, … } |
守卫是 isAllowedRequest(与 state / lexicon / baseline 等路由同一条):要求连接来源是回环地址、Host 头解析出的主机名是 127.0.0.1 / localhost / [::1]、且不是跨站请求;否则只有 allowLanState 打开才放行。仅支持回环是刻意的:这是删书入口,不该暴露到局域网。GET 的用途是面板读取(list / preview),真正会写盘的 action 走 POST。
已知边界
- 移入回收站不释放磁盘空间:内容只是被
rename到trash/下,字节仍在盘上;面板「数据目录」的体积统计包含trash/,所以删书之后体积会变大。要真正释放空间,必须对条目执行purge(不可恢复)。 trash/不会自动清理:插件不设回收站过期策略,也不在你删书时顺手清旧条目;长期使用需要自己按action: "list"决定何时purge。- 恢复是文件粒度的「移回」,不是版本合并:目标已存在就跳过并记入
conflicts,绝不覆盖;目录已被重建时逐文件合并恢复,同名文件一律跳过。恢复不会做任何内容合并或改写。 - 没有清单就不能一键恢复:若
manifest.json写入失败(或目录是手动放进回收站的),文件确实在回收站里,但restore会报错并给出目录路径,只能手动移回。 - 书名里的
..不会把删除带出书库(v7.3.1 修正):旧版resolveBookFolder在没匹配到真实目录时原样返回传入的书名,而blockReasonFor只拦「点号开头」——于是x/../../某目录这类书名能让删除目标逃出novels/(实测可把库外目录搬进回收站)。现在未匹配一律按清洗后的书名(sanitizeSegment)处理,并在构建目标前断言所有目标都在书库根内,否则整体blocked。反向的restore同样加了校验:清单里items[].from必须在书库根内、to必须在本次回收站条目目录内,越界条目不动、进failed并留在stillThere(数据不丢)。v7.3.2 起边界校验改为解析真实路径(realPathOf:取「存在的最深祖先」做realpath再拼回剩余段)——旧版只做resolve词法比较,书库根内若被预置 junction / 符号链接,词法在根内的路径可以经链接写到根外;删除、恢复、以及恢复时的逐文件落点现在都过一遍真实路径。词法上的..本就无法绕过(resolve会折叠)。 - 逐项失败不中断:某一项被占用 / 无权限时,其余项照常搬走,失败项进
failed,并用remaining报告清理后残留——也就是可能出现「删了一部分」。预览清单与执行后的残留要一起看。 - 选择性清理后
remaining不再是「残留」:传了keys时,没选中的位置本来就该留着,所以remaining.files > 0属正常——判断"是不是出了意外"要看partial与skipped,不要看到remaining非零就以为失败。 - 同名清洗冲突会牵连另一本书:两本书名清洗后相同(例如只差非法字符)时,插件按清洗后的名字落盘,两者的数据文件本来就共用;删除一本会一并清掉,
warnings会点名另一本书。这是命名层面的既有约束,不是本工具新增的行为。 - 跨卷时会更慢,且期间占双份空间:同卷
rename是瞬时的;回收站与书库不在同一卷时退化为「复制 + 删除」(EXDEV),复制期间源与目标同时存在。 - 书名会被
trim与清洗:前后空格会被去掉,非法字符会被sanitizeSegment处理;参数书名与盘上目录名不一致时,以解析出的真实目录名为准,并在warnings里说明。 - 「13 个主位置」是白名单,不覆盖未来新增的目录:数据目录下新增按书子目录时必须登记进
DELETE_DATA_SUBDIRS(扁平文件)或单独登记为kind:"dir"目标(如drafts),否则会漏删(测试里有断言盯着这条)。 - 旧位置伏笔表是「懒迁移」的产物:
plots/<书>.json不存在而数据目录根上有<书>.json时,readPlots会把内容复制到新位置(readOnly路径除外),但不会删掉旧文件,于是两份并存。本工具把残留的旧文件当作要清理的内容之一;如果这本书的伏笔表从来没被读过(旧文件是唯一一份),删前先让 AI 跑一次novel_plot list完成迁移,或先读出来备份。 - 只处理「按书命名」的东西:不属于这本书的散落文件、全局词表、插件设置、事件日志、其他书一律不动;反过来,若有工具用了不按书名命名的共享文件,本工具也不会替你判断归属。
- 面板与工具同源,但路由仅回环:
POST /api/dsh-novel-writer/delete-book只在回环地址可用(非 loopback → 403);远程访问面板时删书入口会被拒绝,这是设计而不是故障。
相关工具
- novel_books —— 删书前用它确认真实书名(
novels/下的目录名);书名写错时本工具会返回found:false,不会误删。 - novel_outline · novel_plot · novel_settings · novel_summary · novel_lexicon —— 这些工具的数据文件正是被清理的
creation/与数据目录 10 个按书子目录(含drafts/);删书后它们会回到「尚未初始化 / 未登记」的状态(这是预期,不是损坏)。全局词表_global.json不受影响。 - novel_diagnose —— 想先确认数据目录里到底有哪些按书文件、有没有损坏或残留,用它体检;删书后的残留也可以用它的数据目录体检复核。
- novel_sentence_config —— 本工具会被工具开关关闭(关闭时按普通工具抛错);需要重新开启时用它或侧边栏面板。
- novel_style_report / novel_sentence_analysis —— 它们的产出就是本工具要清理的「报告历史」。只想删其中某一份报告时,用面板「报告历史」里那一行的「删除」:它同样进回收站(条目名「(报告历史)」),不会直接消失。
源码位置
以符号名(函数名 / 常量名)为准:行号是 v6.8.0 定稿时的位置,只作快速跳转用,实现一增删就会整体漂移。
- lib/trash.js —— 回收站的公共机制(v6.8.0 抽出):
TRASH_SCHEMA/TRASH_DIRNAME/isValidTrashId/stampOf/movePath(跨卷EXDEV退化为复制+删除)/movePathsToTrash(面板「报告历史」逐条删除用它)。只依赖 node 内置,故core.js与lib/bookdelete.js都能导入而不成环;两条删除路径共用同一份清单结构,恢复端不会分叉 - lib/bookdelete.js —— 新增模块(约 700 行)。清单与边界:
DELETE_DATA_SUBDIRS(9 个扁平按书子目录白名单;drafts另按整目录登记)、isBookScopedName(前缀 + 分隔符边界匹配)、selectTargets(按keys选择要清哪几处)、resolveBookFolder(原始书名 / 清洗名等价定位)。五个动作:planBookDelete(预览)、applyBookDelete(按选择移入回收站 + 写清单)、restoreTrash(逐项移回、冲突跳过)、listTrash(条目列表)、purgeTrash(彻底删除);渲染formatBookDelete - lib/index.js ——
DELETE_ACTIONS与deleteResultFor(所有字段常驻的默认值)、deleteBookDispatch(工具与面板路由共用的唯一执行链路)、registerNovelDeleteBook(第 21 个工具的参数 / 输出 schema 与execute)、/api/dsh-novel-writer/delete-book路由(GET / POST、403 / 405 / 400 / 500)、apply()里的注册调用(在诊断之后);v7.3.0 起planBookDelete还单独登记一项data:drafts(草稿镜像.novel-writer/drafts/<书>/,kind:"dir"整目录移入回收站——它不放进扁平文件白名单,因为那套按「书名前缀+边界」匹配data/<子目录>/下的文件,草稿是"按书一个目录",放进去会永远命中 0 个而被跳过) - lib/core.js ——
ALL_TOOLS第 21 项novel_delete_book、TOOL_LABELS(「按书删除」)、legacyPlotsFile(旧位置<书>.json)、sanitizeSegment(书名清洗)、novelDataDir(数据目录定位)、CREATION_DIR/GLOBAL_LEXICON_BOOK(保留名);以及报告路由的POST { action:"forget", file }——面板「报告历史」逐条删除走它,单份报告同样进回收站(条目名「(报告历史)」) - lib/client.js —— 首页第一分区的「删除整本书」导航行、
view === "trash"的删除页(选书列表 → 内联预览 → 结果,下方常驻回收站)、书库概况里的快捷删除按钮与对应弹窗、DELETE_ROUTE/deleteBookRoute、loadDeletePlan(页面与弹窗共用)/deletePick/deleteOpen/deleteApply、trashLoad/trashRestore/trashPurge、deleteBlockRows(预览区块,两处共用),以及del.*/trash.*文案键(v6.7.0 改版后行号已变,请按符号名检索) - tools/client-src/V6-components.css ——
v7-D: 按书删除 / 回收站段:书库行上的删除入口、弹窗内的位置清单、回收站条目行三个积木 - test/delete-book-test.mjs —— 12 项回归:匹配边界、预览不写盘、只处理所选书籍、恢复、恢复冲突、彻底删除、拒绝项、输出契约、路由、隔离守卫