← dsh-novel-writer 技术文档 / novel_delete_book

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/ 不会自动清理。

参数

参数类型必填说明
bookstringpreview / apply 必填书名,应与 novels/ 下的目录名一致。取值先 trim,再经 sanitizeSegment 清洗;盘上目录名与清洗名两种写法都能定位到同一本书(先按原名精确找,再按清洗名等价匹配)。list / restore / purge 不需要它。
actionstring否preview(默认,只算不写盘)/ apply(执行删除,移入回收站)/ list(列出回收站条目)/ restore(从回收站恢复,需 trashId)/ purge(彻底删除某个回收站条目,需 trashId,不可恢复)。五个合法取值由参数 schema 的 enum 约束;执行链路对枚举外的值一律按 preview 处理。
trashIdstringrestore / purge 必填回收站条目 id,取自 apply 返回的 trashId 或 action: "list" 的条目 id。只接受单段名:首尾空格会先被去掉(requiredString 的常规 trim),随后含路径分隔符(/、\)、含 ..、以点号开头、或为空则报错;校验通过后还会确认解析结果确实落在回收站目录内。
rootstring否章节库根目录(含 novels 子目录)。省略时按插件既有的根解析顺序回退(面板路由走 config.root → lastRoot 同一套口径,不另起一套)。
keysarray<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。

字段类型内容
actionstring本次生效的 action(默认 preview)
book / safeBookstringbook 是定位到的盘上目录名(也就是返回体里给人看的书名);safeBook 是清洗后的写法,回收站条目名以它为前缀
foundboolean是否找到这本书的任何内容(正文目录、创作资料与数据目录里的按书文件任一存在)。list 时为「回收站非空」
blocked / blockedReasonboolean / string书名命中保留名(_global、创作资料、点号开头)时为 true 并给出中文原因;preview 只标记不抛错,apply 直接报错
targetsarray待清理位置清单,一处一行。每项为 { key, label, kind, path, rel, exists, files, bytes, sample[] };kind ∈ dir(整目录移走)/ files(只移走匹配文件,目录留着)/ state(只从 state.json 摘掉本书条目)
targets[].samplearray样例文件名,最多 4 个;用于在确认弹窗里让用户认得出「这些确实是我的文件」,不是完整清单
totalsobject{ files, bytes }:只合计本次选中的位置(传 keys 时即所选那几处;省略 keys 时等于全部 targets);purge 时为被删条目的合计。不是按全量 targets 累加——只清缓存时不会把正文体积累加进来
warningsarray需要留意的提示:同名清洗冲突、书名被清洗、只有关联资料没有正文、设定库条目名与参数不同、清单写入失败、逐项失败、清理后仍有残留等
notTouchedarray明确不受影响的东西(全局词表 / 插件通用设置 / 事件日志 / 其他书的正文与资料),逐条写成中文
trasharray仅 list 时有内容:条目为 { id, book, createdAt, files, bytes, items, restorable, path },按 createdAt 新→旧排序
trash[].restorableboolean是否有可用的 manifest.json。没有清单的条目(清单写入失败 / 手动放进回收站)仍会列出,但不能一键恢复
trashId / trashDirstring本次生成的回收站条目 id 与绝对路径。一项都没搬动且设定库未移除时会删掉空目录,两者均为空串
movedinteger实际移走的项数(目录项按 1 项计,文件项按文件计);不等于 totals.files
profileRemoved / profileRestoredbooleanapply 时是否同时移除了设定库里的本书条目;restore 时是否恢复了它
restoredintegerrestore 成功恢复的文件数:整目录恢复也按目录内实际文件数计(空目录按 1 计);目录已被重建而逐文件合并时,按实际移回的文件数计
conflictsarrayrestore 时原位置已有同名内容而跳过的路径(含设定库条目名)。绝不覆盖,也不做合并改写
failedarray逐项失败:{ path, code, error }。error 为「code + 截断到 160 字的错误消息」;某一项失败不中断其余项
remainingobjectapply 后再跑一次预览得到的残留 { files, bytes }——被占用 / 无权限的文件会留在这里,如实报告
purgedbooleanpurge 是否成功;成功即不可恢复
messagestring一句话结论,例如「预览:《孤舟》共 14 个文件待清理(磁盘未改动)」「已将《孤舟》的 5 项(14 个文件 / 204800 字节)移入回收站,并移除设定库条目」

渲染层(output.render)返回 [{ type: "text", text: formatBookDelete(value) }]:一份固定格式的纯文本,首行是 【按书删除 · 预览】《书名》 或 【按书删除 · 已执行】《书名》,随后逐行列出将清理 / 已清理的位置(标签 · 文件数 · 体积 · 相对路径 · 样例),再接「不会动」一句与 warnings。执行后另附回收站路径、trashId(含可照抄的 action=restore trashId=… 写法)、残留文件数与失败项。预览末尾固定写「以上为预览,磁盘上还没有任何改动」。

行为与实现

1 · 五个 action

action必填行为写盘
previewbook计算清单:13 个主位置 + 旧位置逐个判断存在性、文件数、体积与样例,并根据书名产出 blocked / warnings / notTouched否
applybook先算一遍清单(与预览同一套 planBookDelete),按 keys(省略=全选)决定处理哪些位置,再逐项移入回收站、摘掉设定库条目、写 manifest.json,最后再算一遍残留是
list—读取 trash/,按清单或目录时间倒序列出条目;无清单的条目标 restorable:false否
restoretrashId按清单把内容移回原路径;目标已存在则逐文件跳过并记入 conflicts;全部恢复完才删除回收站条目是
purgetrashId彻底删除一个回收站条目(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/<书>/dirv7.3.0 新增:实时聊天写作的逐段镜像,按书一个目录,整目录移入回收站(不放进扁平文件白名单,见源码位置说明)
⑬profile设定库条目 state.json 的 creationProfiles[<书>]state不是文件;只在 state.json 里摘掉或写回这一本书的键,开关 / 容差 / 提示词档位 / 其他书条目一律保留
旧legacy旧位置伏笔表 .novel-writer/<书>.jsonfilesv0.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」必然漏。

但反过来也不能用「文件名包含书名」或「以书名开头就算」:那样删《孤舟》会顺手删掉《孤舟子》的资料。匹配口径因此是前缀 + 分隔符边界:

isBookScopedName(name, keys): name === key → 命中(旧位置 / 各种 <书>.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 与冲突跳过

apply 的执行顺序(顺序本身是安全设计): 1. planBookDelete(与预览同一套)→ blocked / found 任一不满足即报错(未改盘) 2. 建回收站目录:<数据目录>/trash/<trashId>/ trashId = <safeBook>-<YYYYMMDD-HHmmss>,撞名时追加 -2、-3 … 3. 逐个 target 移动:dir=整目录;files=逐文件 同卷 rename 瞬时;跨卷 EXDEV 退化为「复制 + 删除」(更慢,期间占双份空间) 单项失败记入 failed[] 并继续下一项 —— 不整体回滚(半途中止会让人既不知道删了什么、也无法恢复) 4. 设定库:只摘掉 creationProfiles[本书],原值备份 5. 写 manifest.json —— 放在移动之后:万一中途崩溃,至少「已搬走的东西」还能恢复 6. 再跑一次 planBookDelete → remaining(被占用 / 无权限的文件会留在这里) 7. 一项都没搬动、设定库也未移除 → 删掉空回收站目录,trashId 置空(不留空条目)
manifest.json(schema: 1): { schema: 1, book, rawBook, safeBook, root, createdAt, items: [{ from, to, key, dir?: true, files, bytes }], ← 每一项的原路径与回收站路径 profile: { key, value } | null, ← 设定库条目的备份 files, bytes }

restore 完全按清单逆操作,规则只有一条:绝不覆盖。

  • 目标路径不存在:整项(目录则整目录)移回原路径。
  • 目标目录已被重建(同名重开是最常见的场景):不能整块跳过,否则会出现「点了恢复,什么都没回来」。改为逐文件合并恢复:能回的都回,只有同名文件才跳过并记入 conflicts。
  • 文件项的目标已存在:跳过并记入 conflicts,不做覆盖、不做合并改写。
  • 设定库条目:若同名键已被新书占用则跳过(记入 conflicts),否则按备份原样写回。
  • 收尾:全部恢复成功才删除回收站条目;仍有未恢复项时,把 manifest.json 重写为「只含未恢复项」,下次可重试(已存在的项会再次被当冲突跳过,幂等)。

purge 是唯一不可恢复的 action:校验 trashId 为单段名、解析结果在回收站目录内,然后整个条目删除;删完仍存在就报错(多半被其他程序占用)。action: "list" 会把没有清单的目录也列出来(restorable:false),因为「清单写得失败但文件确实在回收站里」是已知情形——这类条目只能手动移回,不能一键恢复。

5 · 拒绝项

情形previewapply
书名为空(trim 后)报错「参数 book 不能为空字符串」同左
清洗后等于 _globalblocked:true + 原因:「书名与全局词表文件同名」直接报错,未做任何改动
原始或清洗后等于 创作资料blocked:true + 原因:「是插件的保留目录」直接报错
以点号开头(.novel-writer 等)blocked:true + 原因:「点号开头的目录是保留位置」直接报错
. / ..在清洗阶段即被拒同左
一处内容都找不到found:false + 提示先确认书名报错「没找到《书》的任何内容」
trashId 非法—报错:只接受单段名(首尾空格先被 trim 掉,随后才校验);/、\、..、点号开头、空串一律拒绝
回收站条目没有清单list 标 restorable:falserestore 报错并给出可手动移回的目录路径

注意区分「拒绝」与「找不到」:拒绝是书名本身不允许删(可能误伤插件自己的文件),预览阶段就要让人看见原因;找不到是书名写错或这本书已经删过,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 · 宿主路由与错误码

POST /api/dsh-novel-writer/delete-book ← 面板执行(action=apply / restore / purge / list) GET /api/dsh-novel-writer/delete-book?action=&book=&trashId= ← 面板读取(action=list 与 action=preview) 请求体(POST):{ action, book, trashId, keys }(四项都可省,action 缺省 preview;keys 只在 apply 时生效) 成功 → 200 { ok: true, ...与工具完全一致的返回体 }
情形状态码返回
非回环地址(且未开 allowLanState)403{ error: "forbidden: loopback-only" }
方法不是 GET / POST405{ 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 项回归:匹配边界、预览不写盘、只处理所选书籍、恢复、恢复冲突、彻底删除、拒绝项、输出契约、路由、隔离守卫