rebuilder.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. import { promises as fs } from 'node:fs'
  2. import path from 'node:path'
  3. import { parseFrontMatter } from '../storage/parsers/front-matter.js'
  4. import { parseMarkdownTable } from '../storage/parsers/markdown-table.js'
  5. /**
  6. * 全量重建缓存(BEGIN → DELETE 六表 → 扫描源文件 INSERT → COMMIT,中途失败 ROLLBACK 不留半库)。
  7. * @param {string} repoPath - 书仓库根目录
  8. * @param {DatabaseSync} db - node:sqlite 数据库实例
  9. * @returns {Promise<{ok: boolean, warnings: string[], errors: string[]}>}
  10. */
  11. export async function rebuildCache(repoPath, db) {
  12. const warnings = []
  13. const errors = []
  14. try {
  15. // P0-3/P1-1:整次重建包在一个事务里,别名冲突等中途失败 → ROLLBACK,不留半填库
  16. db.exec('BEGIN')
  17. // 1. 清空六表(chapters/threads/secrets/entities/entity_aliases/fingerprints)
  18. db.exec('DELETE FROM chapters')
  19. db.exec('DELETE FROM threads')
  20. db.exec('DELETE FROM secrets')
  21. db.exec('DELETE FROM entities')
  22. db.exec('DELETE FROM entity_aliases')
  23. db.exec('DELETE FROM fingerprints')
  24. // 2. 扫描章节文件 → 填充 chapters 表
  25. const chapterMap = await scanChapters(repoPath, db)
  26. // 3. 扫描条目文件 → 填充 threads 表(验证履历章节)
  27. await scanThreads(repoPath, db, chapterMap, warnings)
  28. // 4. 扫描信息差 → 填充 secrets 表
  29. await scanSecrets(repoPath, db)
  30. // 5. 解析名册 → 填充 entities + entity_aliases 表(验证别名唯一性)
  31. const aliasCheck = await scanEntities(repoPath, db)
  32. if (aliasCheck.warnings) warnings.push(...aliasCheck.warnings)
  33. if (!aliasCheck.ok) {
  34. db.exec('ROLLBACK')
  35. errors.push(...aliasCheck.errors)
  36. return { ok: false, warnings, errors }
  37. }
  38. // 6. 扫描角色卡 → 填充 entities 表
  39. await scanCharacters(repoPath, db)
  40. // 7. fingerprints 表留空(特征提取随 M3+ 体检补)
  41. db.exec('COMMIT')
  42. return { ok: true, warnings, errors }
  43. } catch (err) {
  44. try {
  45. db.exec('ROLLBACK')
  46. } catch {
  47. // 未处于事务中,忽略
  48. }
  49. errors.push(`重建失败:${err.message}`)
  50. return { ok: false, warnings, errors }
  51. }
  52. }
  53. /**
  54. * 扫描章节文件,填充 chapters 表。
  55. * @returns {Promise<Map<number, string>>} 章号 → 文件路径映射
  56. */
  57. async function scanChapters(repoPath, db) {
  58. const chapterDir = path.join(repoPath, '定稿', '正文')
  59. const chapterMap = new Map()
  60. try {
  61. const files = await fs.readdir(chapterDir)
  62. const insertStmt = db.prepare(`
  63. INSERT INTO chapters (chapter_num, title, volume_num, perspective, story_time, word_count, chapter_position, hook_type, mood_position, is_volume_end, file_path, is_key_chapter)
  64. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  65. `)
  66. for (const file of files) {
  67. if (!file.endsWith('.md')) continue
  68. const filePath = path.join(chapterDir, file)
  69. const content = await fs.readFile(filePath, 'utf8')
  70. const parsed = parseFrontMatter(content)
  71. if (parsed.ok) {
  72. const fm = parsed.data
  73. const chapterNum = fm.章号 || parseInt(file.match(/\d+/)?.[0] || '0', 10)
  74. insertStmt.run(
  75. chapterNum,
  76. fm.标题 || '未命名',
  77. fm.卷 || 1,
  78. fm.视角 || null,
  79. fm.书内时间 || null,
  80. fm.字数 || 0,
  81. fm.章定位 || '推进',
  82. fm.钩子 || null,
  83. fm.情绪定位 || null,
  84. fm.收卷 === '是' || fm.收卷 === true ? 1 : 0,
  85. filePath,
  86. fm.是否关键章 ? 1 : 0
  87. )
  88. chapterMap.set(chapterNum, filePath)
  89. }
  90. }
  91. } catch (err) {
  92. // 目录不存在,跳过
  93. }
  94. return chapterMap
  95. }
  96. /**
  97. * 扫描条目文件,填充 threads 表。
  98. */
  99. async function scanThreads(repoPath, db, chapterMap, warnings) {
  100. const types = [
  101. { dir: '伏笔', type: 'foreshadow' },
  102. { dir: '悬念', type: 'suspense' },
  103. { dir: '感情线', type: 'romance' },
  104. ]
  105. const insertStmt = db.prepare(`
  106. INSERT INTO threads (id, type, short_title, strength, status, opened_chapter, planned_end, last_advanced_chapter, file_path)
  107. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
  108. `)
  109. for (const { dir, type } of types) {
  110. const threadDir = path.join(repoPath, '大纲', dir)
  111. try {
  112. const files = await fs.readdir(threadDir)
  113. for (const file of files) {
  114. if (!file.endsWith('.md')) continue
  115. const filePath = path.join(threadDir, file)
  116. const content = await fs.readFile(filePath, 'utf8')
  117. const parsed = parseFrontMatter(content)
  118. if (parsed.ok) {
  119. const fm = parsed.data
  120. const id = file.replace('.md', '').split('-').slice(0, 2).join('-') // 伏笔-001
  121. // 验证履历章节存在性
  122. const historySection = extractSection(parsed.body, '履历')
  123. if (historySection) {
  124. const historyChapters = parseHistoryChapters(historySection)
  125. for (const ch of historyChapters) {
  126. if (!chapterMap.has(ch)) {
  127. warnings.push(`条目 ${id} 履历引用章节 ${ch} 不存在`)
  128. }
  129. }
  130. }
  131. insertStmt.run(
  132. id,
  133. type,
  134. id, // short_title 简化为 id
  135. fm.强度 || '中',
  136. fm.状态 || '进行',
  137. fm.开启章 || 1,
  138. fm.预计收尾 || null,
  139. fm.最后推进章 || fm.开启章 || 1,
  140. filePath
  141. )
  142. }
  143. }
  144. } catch (err) {
  145. // 目录不存在,跳过
  146. }
  147. }
  148. }
  149. /**
  150. * 扫描信息差,填充 secrets 表。
  151. */
  152. async function scanSecrets(repoPath, db) {
  153. const secretDir = path.join(repoPath, '定稿', '设定', '信息差')
  154. const insertStmt = db.prepare(`
  155. INSERT INTO secrets (id, short_title, known_to, reader_knows, registered_chapter, keywords, file_path)
  156. VALUES (?, ?, ?, ?, ?, ?, ?)
  157. `)
  158. try {
  159. const files = await fs.readdir(secretDir)
  160. for (const file of files) {
  161. if (!file.endsWith('.md')) continue
  162. const filePath = path.join(secretDir, file)
  163. const content = await fs.readFile(filePath, 'utf8')
  164. const parsed = parseFrontMatter(content)
  165. if (parsed.ok) {
  166. const fm = parsed.data
  167. const stem = file.replace('.md', '')
  168. const id = stem.split('-').slice(0, 2).join('-')
  169. const shortTitle = stem.split('-').slice(2).join('-') || id // 信息差-021-灭门真凶 → 灭门真凶
  170. insertStmt.run(
  171. id,
  172. shortTitle,
  173. JSON.stringify(fm.知情人 || []),
  174. fm.读者已知 ? 1 : 0,
  175. fm.登记章 || 1,
  176. JSON.stringify(fm.关键词 || []),
  177. filePath
  178. )
  179. }
  180. }
  181. } catch (err) {
  182. // 目录不存在,跳过
  183. }
  184. }
  185. /**
  186. * 解析名册,填充 entities + entity_aliases 表(验证别名唯一性)。
  187. * 名册非必需:缺失则跳过(角色卡可独立入 entities);解析失败/别名冲突才算硬错。
  188. */
  189. async function scanEntities(repoPath, db) {
  190. const rosterPath = path.join(repoPath, '定稿', '设定', '名册.md')
  191. const aliasMap = new Map() // alias → entity_id
  192. let content
  193. try {
  194. content = await fs.readFile(rosterPath, 'utf8')
  195. } catch {
  196. // 无名册:跳过(角色卡 scanCharacters 仍会入档),不算失败
  197. return { ok: true, errors: [] }
  198. }
  199. try {
  200. const table = parseMarkdownTable(content)
  201. if (!table.ok) {
  202. // 名册格式解析不动 → 软跳过(不阻断重建;chapters/threads/secrets 不应因此回滚)。
  203. // 别名冲突才是硬错(见下),走 ok:false 触发 ROLLBACK。
  204. return { ok: true, errors: [], warnings: [`名册解析失败,已跳过实体/别名入库:${table.error}`] }
  205. }
  206. const entityStmt = db.prepare(`
  207. INSERT INTO entities (id, type, status, location, realm, possessions, last_changed_chapter, file_path)
  208. VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  209. `)
  210. const aliasStmt = db.prepare(`
  211. INSERT INTO entity_aliases (alias, entity_id)
  212. VALUES (?, ?)
  213. `)
  214. for (const row of table.rows) {
  215. const entityId = row.正名
  216. const type = row.类型 || 'character'
  217. entityStmt.run(entityId, type, null, null, null, null, parseInt(row.首现章 || '1', 10), rosterPath)
  218. // 处理别名
  219. const aliases = row.别名.split(',').map((a) => a.trim()).filter((a) => a)
  220. for (const alias of aliases) {
  221. if (aliasMap.has(alias)) {
  222. return {
  223. ok: false,
  224. errors: [`别名冲突:「${alias}」同时指向「${aliasMap.get(alias)}」和「${entityId}」`],
  225. }
  226. }
  227. aliasMap.set(alias, entityId)
  228. aliasStmt.run(alias, entityId)
  229. }
  230. }
  231. return { ok: true, errors: [] }
  232. } catch (err) {
  233. return { ok: false, errors: [`名册扫描失败:${err.message}`] }
  234. }
  235. }
  236. /**
  237. * 扫描角色卡,填充 entities 表。
  238. */
  239. async function scanCharacters(repoPath, db) {
  240. const charDir = path.join(repoPath, '定稿', '设定', '角色')
  241. // upsert:角色卡可能不在名册里(名册非强制全覆盖),只 UPDATE 会丢这些角色。
  242. // 在名册里则补全字段并把 file_path 指向更详细的角色卡。
  243. const upsertStmt = db.prepare(`
  244. INSERT INTO entities (id, type, status, location, realm, possessions, last_changed_chapter, file_path)
  245. VALUES (?, 'character', ?, ?, ?, ?, ?, ?)
  246. ON CONFLICT(id) DO UPDATE SET
  247. status = excluded.status,
  248. location = excluded.location,
  249. realm = excluded.realm,
  250. possessions = excluded.possessions,
  251. last_changed_chapter = excluded.last_changed_chapter,
  252. file_path = excluded.file_path
  253. `)
  254. try {
  255. const files = await fs.readdir(charDir)
  256. for (const file of files) {
  257. if (!file.endsWith('.md')) continue
  258. const name = file.replace('.md', '')
  259. const filePath = path.join(charDir, file)
  260. const content = await fs.readFile(filePath, 'utf8')
  261. const parsed = parseFrontMatter(content)
  262. if (parsed.ok) {
  263. const fm = parsed.data
  264. upsertStmt.run(
  265. name,
  266. fm.状态 || null,
  267. fm.位置 || null,
  268. fm.境界 || null,
  269. JSON.stringify(fm.持有 || []),
  270. fm.最后变更章 || 1,
  271. filePath
  272. )
  273. }
  274. }
  275. } catch (err) {
  276. // 目录不存在,跳过
  277. }
  278. }
  279. /**
  280. * 从正文提取指定段落。
  281. */
  282. function extractSection(body, sectionTitle) {
  283. const lines = body.split('\n')
  284. let inSection = false
  285. const sectionLines = []
  286. for (const line of lines) {
  287. if (line.startsWith('##')) {
  288. if (inSection) break
  289. if (line.includes(sectionTitle)) {
  290. inSection = true
  291. continue
  292. }
  293. }
  294. if (inSection) sectionLines.push(line)
  295. }
  296. return sectionLines.join('\n').trim()
  297. }
  298. /**
  299. * 解析履历中的章号。
  300. */
  301. function parseHistoryChapters(historyText) {
  302. const chapters = []
  303. const lines = historyText.split('\n')
  304. for (const line of lines) {
  305. const match = line.match(/第(\d+)章/)
  306. if (match) {
  307. chapters.push(parseInt(match[1], 10))
  308. }
  309. }
  310. return chapters
  311. }