#393·inkos

export 静默丢弃未进索引的章节文件,且与 inkos status 的章节数口径不一致(同一本书 16 vs 15)

Author: pengyzCreated Sep 15, 2026Updated Sep 15, 2026

环境

  • @actalk/inkos 1.8.0,Node v24.14.0,Linux
  • 书籍:15 章在索引内(chapters/index.jsonnumber 为 1..15),chapters/ 目录下有 16.md 文件

现象

同一份章节数据有两个互相矛盾的口径,且未进索引的文件被静默丢弃、零警告

bash
$ 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):

javascript
: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/exportPOST …/export-save、对话层 agent-tools.ts 的 exporter 分支、interaction/project-tools.ts:169

为什么 chapters/ 里会出现"不在索引内"的文件

state/manager.jsrebuildChapterIndexFromFilesAt(索引缺失/损坏时的重建回退)明确跳过 number <= 0

javascript
if (!Number.isFinite(number) || number <= 0) return [];

说明:这一行是排除语义("不要把 0/负数当章节"),并非"为无编号序章预留"。我核实过上游全仓(TS / 文档 / 测试 / i18n)没有 prologue / 序章 / 0000_ 之类的约定、常量或测试;InkOS 的模型就是"章节 = 1..N"。

因此这是用户侧的自建约定:把序章命名为 0000_*.md 放在 chapters/ 下,它可以长期存在而不被索引认领——而导出会静默丢掉它。我不是在主张上游应支持序章,而是在报告"静默丢文件 + 无告警"这个行为本身。

影响

  • 交付/投稿时缺章,且用户拿不到任何提示——inkos statusChapters: 16 甚至会让人以为它被算进去了;
  • 任何"手动往 chapters/ 放文件"或"索引与文件不同步"的场景都会命中。

复现

bash
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(至少让用户知道少了东西):

javascript
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 约定的文件):

javascript
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。

同时建议统一 statusexport 的口径:现在一个数文件、一个数索引,用户无法判断哪个是对的。

附:本项目的临时绕行(仅供参考)

调用官方 export 之后,把 chapters/0000_*.md 手工拼回开头。本项目即如此处理(输出约 83260 字,序章在前)。