export 静默丢弃未进索引的章节文件,且与 inkos status 的章节数口径不一致(同一本书 16 vs 15)
环境
@actalk/inkos1.8.0,Node v24.14.0,Linux- 书籍:15 章在索引内(
chapters/index.json的number为 1..15),chapters/目录下有 16 个.md文件
现象
同一份章节数据有两个互相矛盾的口径,且未进索引的文件被静默丢弃、零警告:
$ inkos status
Chapters: 16 # ← 数的是 chapters/ 下的 .md 文件数
totalWords: 77591 # ← 按索引算
$ inkos export <book> --output /tmp/out.txt
Exported 15 chapters (77591 words) # ← 按索引算,第 16 个文件不见了
Output: /tmp/out.txt # exit 0,无任何 warning
$ grep -c "楔子" /tmp/out.txt
0导出文件首 5 行:
南渡北辰
(空)
(空)
# 第001章 元宵夜宴
(空)那一章(chapters/0000_楔子_坠入深渊.md,448 行)完全不在导出里,而命令返回成功、没有一句提示。
--json 输出同样是干净的:{ bookId, chaptersExported: 15, totalWords: 77591, format, outputPath } —— 没有 warnings 字段。
根因
@actalk/inkos-core/dist/interaction/export-artifact.js(TS: packages/core/src/interaction/export-artifact.ts):
:36 const index = await state.loadChapterIndex(bookId); // 数据源 = 索引
:38 const chapters = options.approvedOnly
:39 ? index.filter((chapter) => chapter.status === "approved")
:40 : index;
:48 const chapterFiles = buildChapterFileLookup(await readdir(chaptersDir));
...
:76 for (const chapter of chapters) { // 遍历的是索引
:77 const match = chapterFiles.get(chapter.number);
:78 if (!match) { continue; }
:79 parts.push(await readFile(...));readdir 只用于"索引条目 → 文件名"的映射,没有任何补扫未索引文件的回退。唯一的异常分支是 :41 if (chapters.length === 0) throw new Error("No chapters to export.")——即"少了文件"这个方向完全没有告警,只有"一个都没有"才报错。
TS 源码与 dist 逐字一致(export-artifact.ts:68 / 70-72 / 82 / 113-120)。
所有导出口径共用这份实现,所以换个入口也一样:CLI dist/commands/export.js、Studio 的 GET /api/v1/books/:id/export 与 POST …/export-save、对话层 agent-tools.ts 的 exporter 分支、interaction/project-tools.ts:169。
为什么 chapters/ 里会出现"不在索引内"的文件
state/manager.js 的 rebuildChapterIndexFromFilesAt(索引缺失/损坏时的重建回退)明确跳过 number <= 0:
if (!Number.isFinite(number) || number <= 0) return [];说明:这一行是排除语义("不要把 0/负数当章节"),并非"为无编号序章预留"。我核实过上游全仓(TS / 文档 / 测试 / i18n)没有 prologue / 序章 / 0000_ 之类的约定、常量或测试;InkOS 的模型就是"章节 = 1..N"。
因此这是用户侧的自建约定:把序章命名为 0000_*.md 放在 chapters/ 下,它可以长期存在而不被索引认领——而导出会静默丢掉它。我不是在主张上游应支持序章,而是在报告"静默丢文件 + 无告警"这个行为本身。
影响
- 交付/投稿时缺章,且用户拿不到任何提示——
inkos status的Chapters: 16甚至会让人以为它被算进去了; - 任何"手动往
chapters/放文件"或"索引与文件不同步"的场景都会命中。
复现
inkos book create --title T --genre other
inkos import chapters <book> --from <文本> # 得到 index 1..N
cp <任意章节文件> chapters/0000_prologue.md # 放一个不在索引内的文件
inkos status # Chapters 会 +1
inkos export <book> --output /tmp/out.txt # 只导索引内的 N 章,零警告
grep -c "prologue" /tmp/out.txt # 0建议修复
最小:导出时把"存在但未进索引"的文件列进 warning(至少让用户知道少了东西):
const indexed = new Set(chapters.map((c) => c.number));
const unindexed = [...chapterFiles.keys()].filter((n) => !indexed.has(n));
if (unindexed.length > 0) {
console.warn(`[export] ${unindexed.length} file(s) in chapters/ are not in the index and were not exported: ${unindexed.join(", ")}`);
}更彻底:把未索引文件并入导出序列(导出是交付面,宜覆盖 chapters/ 下所有符合 <数字>_*.md 约定的文件):
const indexed = new Set(chapters.map((c) => c.number));
const extras = [...chapterFiles.entries()]
.filter(([n]) => !indexed.has(n) && !options.approvedOnly)
.sort((a, b) => a[0] - b[0]);
const ordered = [...chapters.map((c) => ({ number: c.number, wordCount: c.wordCount })),
...extras.map(([number]) => ({ number, wordCount: undefined }))]
.sort((a, b) => a.number - b.number);
// 两处 for (const chapter of chapters) 改为 for (const item of ordered);
// 取文件仍用 chapterFiles.get(item.number);wordCount 缺失时用 content.replace(/\s+/g,"").length 现算;
// chaptersExported / totalWords 改用 ordered。同时建议统一 status 与 export 的口径:现在一个数文件、一个数索引,用户无法判断哪个是对的。
附:本项目的临时绕行(仅供参考)
调用官方 export 之后,把 chapters/0000_*.md 手工拼回开头。本项目即如此处理(输出约 83260 字,序章在前)。
Source: Narcooo/inkos