| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367 |
- import { promises as fs } from 'node:fs'
- import path from 'node:path'
- import { parseFrontMatter } from '../storage/parsers/front-matter.js'
- import { parseMarkdownTable } from '../storage/parsers/markdown-table.js'
- /**
- * 全量重建缓存(BEGIN → DELETE 六表 → 扫描源文件 INSERT → COMMIT,中途失败 ROLLBACK 不留半库)。
- * @param {string} repoPath - 书仓库根目录
- * @param {DatabaseSync} db - node:sqlite 数据库实例
- * @returns {Promise<{ok: boolean, warnings: string[], errors: string[]}>}
- */
- export async function rebuildCache(repoPath, db) {
- const warnings = []
- const errors = []
- try {
- // P0-3/P1-1:整次重建包在一个事务里,别名冲突等中途失败 → ROLLBACK,不留半填库
- db.exec('BEGIN')
- // 1. 清空六表(chapters/threads/secrets/entities/entity_aliases/fingerprints)
- db.exec('DELETE FROM chapters')
- db.exec('DELETE FROM threads')
- db.exec('DELETE FROM secrets')
- db.exec('DELETE FROM entities')
- db.exec('DELETE FROM entity_aliases')
- db.exec('DELETE FROM fingerprints')
- // 2. 扫描章节文件 → 填充 chapters 表
- const chapterMap = await scanChapters(repoPath, db)
- // 3. 扫描条目文件 → 填充 threads 表(验证履历章节)
- await scanThreads(repoPath, db, chapterMap, warnings)
- // 4. 扫描信息差 → 填充 secrets 表
- await scanSecrets(repoPath, db)
- // 5. 解析名册 → 填充 entities + entity_aliases 表(验证别名唯一性)
- const aliasCheck = await scanEntities(repoPath, db)
- if (aliasCheck.warnings) warnings.push(...aliasCheck.warnings)
- if (!aliasCheck.ok) {
- db.exec('ROLLBACK')
- errors.push(...aliasCheck.errors)
- return { ok: false, warnings, errors }
- }
- // 6. 扫描角色卡 → 填充 entities 表
- await scanCharacters(repoPath, db)
- // 7. fingerprints 表留空(特征提取随 M3+ 体检补)
- db.exec('COMMIT')
- return { ok: true, warnings, errors }
- } catch (err) {
- try {
- db.exec('ROLLBACK')
- } catch {
- // 未处于事务中,忽略
- }
- errors.push(`重建失败:${err.message}`)
- return { ok: false, warnings, errors }
- }
- }
- /**
- * 扫描章节文件,填充 chapters 表。
- * @returns {Promise<Map<number, string>>} 章号 → 文件路径映射
- */
- async function scanChapters(repoPath, db) {
- const chapterDir = path.join(repoPath, '定稿', '正文')
- const chapterMap = new Map()
- try {
- const files = await fs.readdir(chapterDir)
- const insertStmt = db.prepare(`
- 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)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `)
- for (const file of files) {
- if (!file.endsWith('.md')) continue
- const filePath = path.join(chapterDir, file)
- const content = await fs.readFile(filePath, 'utf8')
- const parsed = parseFrontMatter(content)
- if (parsed.ok) {
- const fm = parsed.data
- const chapterNum = fm.章号 || parseInt(file.match(/\d+/)?.[0] || '0', 10)
- insertStmt.run(
- chapterNum,
- fm.标题 || '未命名',
- fm.卷 || 1,
- fm.视角 || null,
- fm.书内时间 || null,
- fm.字数 || 0,
- fm.章定位 || '推进',
- fm.钩子 || null,
- fm.情绪定位 || null,
- fm.收卷 === '是' || fm.收卷 === true ? 1 : 0,
- filePath,
- fm.是否关键章 ? 1 : 0
- )
- chapterMap.set(chapterNum, filePath)
- }
- }
- } catch (err) {
- // 目录不存在,跳过
- }
- return chapterMap
- }
- /**
- * 扫描条目文件,填充 threads 表。
- */
- async function scanThreads(repoPath, db, chapterMap, warnings) {
- const types = [
- { dir: '伏笔', type: 'foreshadow' },
- { dir: '悬念', type: 'suspense' },
- { dir: '感情线', type: 'romance' },
- ]
- const insertStmt = db.prepare(`
- INSERT INTO threads (id, type, short_title, strength, status, opened_chapter, planned_end, last_advanced_chapter, file_path)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
- `)
- for (const { dir, type } of types) {
- const threadDir = path.join(repoPath, '大纲', dir)
- try {
- const files = await fs.readdir(threadDir)
- for (const file of files) {
- if (!file.endsWith('.md')) continue
- const filePath = path.join(threadDir, file)
- const content = await fs.readFile(filePath, 'utf8')
- const parsed = parseFrontMatter(content)
- if (parsed.ok) {
- const fm = parsed.data
- const id = file.replace('.md', '').split('-').slice(0, 2).join('-') // 伏笔-001
- // 验证履历章节存在性
- const historySection = extractSection(parsed.body, '履历')
- if (historySection) {
- const historyChapters = parseHistoryChapters(historySection)
- for (const ch of historyChapters) {
- if (!chapterMap.has(ch)) {
- warnings.push(`条目 ${id} 履历引用章节 ${ch} 不存在`)
- }
- }
- }
- insertStmt.run(
- id,
- type,
- id, // short_title 简化为 id
- fm.强度 || '中',
- fm.状态 || '进行',
- fm.开启章 || 1,
- fm.预计收尾 || null,
- fm.最后推进章 || fm.开启章 || 1,
- filePath
- )
- }
- }
- } catch (err) {
- // 目录不存在,跳过
- }
- }
- }
- /**
- * 扫描信息差,填充 secrets 表。
- */
- async function scanSecrets(repoPath, db) {
- const secretDir = path.join(repoPath, '定稿', '设定', '信息差')
- const insertStmt = db.prepare(`
- INSERT INTO secrets (id, short_title, known_to, reader_knows, registered_chapter, keywords, file_path)
- VALUES (?, ?, ?, ?, ?, ?, ?)
- `)
- try {
- const files = await fs.readdir(secretDir)
- for (const file of files) {
- if (!file.endsWith('.md')) continue
- const filePath = path.join(secretDir, file)
- const content = await fs.readFile(filePath, 'utf8')
- const parsed = parseFrontMatter(content)
- if (parsed.ok) {
- const fm = parsed.data
- const stem = file.replace('.md', '')
- const id = stem.split('-').slice(0, 2).join('-')
- const shortTitle = stem.split('-').slice(2).join('-') || id // 信息差-021-灭门真凶 → 灭门真凶
- insertStmt.run(
- id,
- shortTitle,
- JSON.stringify(fm.知情人 || []),
- fm.读者已知 ? 1 : 0,
- fm.登记章 || 1,
- JSON.stringify(fm.关键词 || []),
- filePath
- )
- }
- }
- } catch (err) {
- // 目录不存在,跳过
- }
- }
- /**
- * 解析名册,填充 entities + entity_aliases 表(验证别名唯一性)。
- * 名册非必需:缺失则跳过(角色卡可独立入 entities);解析失败/别名冲突才算硬错。
- */
- async function scanEntities(repoPath, db) {
- const rosterPath = path.join(repoPath, '定稿', '设定', '名册.md')
- const aliasMap = new Map() // alias → entity_id
- let content
- try {
- content = await fs.readFile(rosterPath, 'utf8')
- } catch {
- // 无名册:跳过(角色卡 scanCharacters 仍会入档),不算失败
- return { ok: true, errors: [] }
- }
- try {
- const table = parseMarkdownTable(content)
- if (!table.ok) {
- // 名册格式解析不动 → 软跳过(不阻断重建;chapters/threads/secrets 不应因此回滚)。
- // 别名冲突才是硬错(见下),走 ok:false 触发 ROLLBACK。
- return { ok: true, errors: [], warnings: [`名册解析失败,已跳过实体/别名入库:${table.error}`] }
- }
- const entityStmt = db.prepare(`
- INSERT INTO entities (id, type, status, location, realm, possessions, last_changed_chapter, file_path)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- `)
- const aliasStmt = db.prepare(`
- INSERT INTO entity_aliases (alias, entity_id)
- VALUES (?, ?)
- `)
- for (const row of table.rows) {
- const entityId = row.正名
- const type = row.类型 || 'character'
- entityStmt.run(entityId, type, null, null, null, null, parseInt(row.首现章 || '1', 10), rosterPath)
- // 处理别名
- const aliases = row.别名.split(',').map((a) => a.trim()).filter((a) => a)
- for (const alias of aliases) {
- if (aliasMap.has(alias)) {
- return {
- ok: false,
- errors: [`别名冲突:「${alias}」同时指向「${aliasMap.get(alias)}」和「${entityId}」`],
- }
- }
- aliasMap.set(alias, entityId)
- aliasStmt.run(alias, entityId)
- }
- }
- return { ok: true, errors: [] }
- } catch (err) {
- return { ok: false, errors: [`名册扫描失败:${err.message}`] }
- }
- }
- /**
- * 扫描角色卡,填充 entities 表。
- */
- async function scanCharacters(repoPath, db) {
- const charDir = path.join(repoPath, '定稿', '设定', '角色')
- // upsert:角色卡可能不在名册里(名册非强制全覆盖),只 UPDATE 会丢这些角色。
- // 在名册里则补全字段并把 file_path 指向更详细的角色卡。
- const upsertStmt = db.prepare(`
- INSERT INTO entities (id, type, status, location, realm, possessions, last_changed_chapter, file_path)
- VALUES (?, 'character', ?, ?, ?, ?, ?, ?)
- ON CONFLICT(id) DO UPDATE SET
- status = excluded.status,
- location = excluded.location,
- realm = excluded.realm,
- possessions = excluded.possessions,
- last_changed_chapter = excluded.last_changed_chapter,
- file_path = excluded.file_path
- `)
- try {
- const files = await fs.readdir(charDir)
- for (const file of files) {
- if (!file.endsWith('.md')) continue
- const name = file.replace('.md', '')
- const filePath = path.join(charDir, file)
- const content = await fs.readFile(filePath, 'utf8')
- const parsed = parseFrontMatter(content)
- if (parsed.ok) {
- const fm = parsed.data
- upsertStmt.run(
- name,
- fm.状态 || null,
- fm.位置 || null,
- fm.境界 || null,
- JSON.stringify(fm.持有 || []),
- fm.最后变更章 || 1,
- filePath
- )
- }
- }
- } catch (err) {
- // 目录不存在,跳过
- }
- }
- /**
- * 从正文提取指定段落。
- */
- function extractSection(body, sectionTitle) {
- const lines = body.split('\n')
- let inSection = false
- const sectionLines = []
- for (const line of lines) {
- if (line.startsWith('##')) {
- if (inSection) break
- if (line.includes(sectionTitle)) {
- inSection = true
- continue
- }
- }
- if (inSection) sectionLines.push(line)
- }
- return sectionLines.join('\n').trim()
- }
- /**
- * 解析履历中的章号。
- */
- function parseHistoryChapters(historyText) {
- const chapters = []
- const lines = historyText.split('\n')
- for (const line of lines) {
- const match = line.match(/第(\d+)章/)
- if (match) {
- chapters.push(parseInt(match[1], 10))
- }
- }
- return chapters
- }
|