index.js 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. import { promises as fs } from 'node:fs'
  2. import path from 'node:path'
  3. import { readV6Project } from './read-v6.js'
  4. import { transformV6 } from './transform.js'
  5. import { createGit } from '../finalize/git.js'
  6. import { CacheManager } from '../cache/index.js'
  7. import { bookAgentsMd } from '../state-machine/persist.js'
  8. import { registerBook, setCurrentBook } from '../session/index.js'
  9. import { sanitizeFileName } from '../util/filename.js'
  10. const TMP_PREFIX = '.migrate-tmp-'
  11. /**
  12. * v6 → v7 一次性迁移(design §3.3):源只读;临时目录建仓(写文件 → git 初始
  13. * commit → 缓存重建验证格式自洽)→ 同盘 rename 落位 → books.jsonl 登记。
  14. * 任一步失败删临时目录整树,工作目录零残留——"整体回退"即"没发生"。
  15. * @param {{workdir: string}} ctx
  16. * @param {string} v6PathArg v6 项目根(相对路径相对 cwd)
  17. * @param {{dir?: string, _faultBeforeRename?: boolean}} opts _faultBeforeRename 仅测试注入用
  18. */
  19. export async function migrateV6(ctx, v6PathArg, opts = {}) {
  20. const v6Path = path.resolve(v6PathArg)
  21. const read = await readV6Project(v6Path)
  22. if (!read.ok) return { ok: false, error: read.error }
  23. if (!read.facts.chapters.length && !read.facts.outlines.master) {
  24. return { ok: false, error: `「${v6Path}」里没有正文也没有总纲,没有可迁移的内容。` }
  25. }
  26. const plan = transformV6(read.facts)
  27. const dirName = sanitizeFileName(opts.dir || plan.bookName)
  28. const target = path.join(ctx.workdir, dirName)
  29. if (await exists(target)) {
  30. return { ok: false, error: `工作目录里已有「${dirName}」,不覆盖既有目录。想另起名字用 --dir=<目录名>。` }
  31. }
  32. await sweepStaleTmp(ctx.workdir) // 上次进程中断的残留,幂等清扫
  33. const tmp = path.join(ctx.workdir, `${TMP_PREFIX}${process.pid}`)
  34. try {
  35. // 1. 写全部映射产物 + 工程三件(AGENTS 指路 / .gitignore)
  36. const written = []
  37. for (const f of plan.files) {
  38. const full = path.join(tmp, f.path)
  39. await fs.mkdir(path.dirname(full), { recursive: true })
  40. await fs.writeFile(full, f.content, 'utf8')
  41. written.push(f.path)
  42. }
  43. for (const [rel, content] of [
  44. ['AGENTS.md', bookAgentsMd(plan.bookName)],
  45. ['.gitignore', '.cache/\n工作区/\n'],
  46. ]) {
  47. await fs.writeFile(path.join(tmp, rel), content, 'utf8')
  48. written.push(rel)
  49. }
  50. // 2. git 初始 commit(v6 提交链压成一个 init,spec §12)
  51. const git = createGit(tmp)
  52. await git.init()
  53. await git.setQuotepathFalse()
  54. await git.ensureIdentity()
  55. await git.add(written)
  56. await git.commit(`init: 迁移自 v6《${plan.bookName}》`)
  57. // 3. 缓存重建 = 格式自洽验证(重建器即参考实现,database 规范 §2.3)
  58. const cache = new CacheManager(path.join(tmp, '.cache', 'index.db'))
  59. try {
  60. await cache.ensureReady(tmp)
  61. const rebuilt = await cache.rebuildFromSource(tmp)
  62. if (rebuilt && rebuilt.ok === false) {
  63. throw new Error(`迁移产物没通过格式校验:${rebuilt.error}`)
  64. }
  65. } finally {
  66. await cache.close()
  67. }
  68. // 4. 迁移报告(工作区不入 git,作者看完可删)
  69. await fs.mkdir(path.join(tmp, '工作区'), { recursive: true })
  70. await fs.writeFile(path.join(tmp, '工作区', '迁移报告.md'), renderReport(plan, v6Path, dirName), 'utf8')
  71. if (opts._faultBeforeRename) throw new Error('注入故障(测试)')
  72. // 5. 同盘 rename 落位
  73. await fs.rename(tmp, target)
  74. } catch (err) {
  75. await fs.rm(tmp, { recursive: true, force: true })
  76. return { ok: false, error: `迁移没有完成,工作目录已恢复原样(v6 项目未被改动)。原因:${err.message}` }
  77. }
  78. // 6. 登记书单并设为当前书(失败不回滚——书已落位,书单可扫描自愈)
  79. let registryNote = ''
  80. try {
  81. await registerBook(ctx.workdir, { 书名: plan.bookName, 目录: dirName })
  82. await setCurrentBook(ctx.workdir, plan.bookName)
  83. } catch (err) {
  84. registryNote = `\n书单登记没写上(${err.message}):跑一次 list-books 会自动扫描重建,不影响已迁移内容。`
  85. }
  86. const c = plan.report.counts
  87. return {
  88. ok: true,
  89. target,
  90. output:
  91. `《${plan.bookName}》迁移完成:正文 ${c.章数} 章、伏笔 ${c.伏笔} 条、角色卡 ${c.角色卡} 张、` +
  92. `章摘要 ${c.摘要} 篇、卷纲 ${c.卷纲} 卷,已设为当前书。\n` +
  93. `迁移报告在 ${dirName}/工作区/迁移报告.md——先看「待校对」一节(${plan.report.待校对.length} 项),` +
  94. `v6 项目原样未动,确认无误前别删。${registryNote}`,
  95. error: '',
  96. }
  97. }
  98. function renderReport(plan, v6Path, dirName) {
  99. const { counts, 待校对, 丢弃, form } = plan.report
  100. const 形态 = { inline: 'state.json 全量内联(老版)', sqlite: 'state.json 精简 + index.db(5.4+)', mixed: '内联与数据库混合' }[form] || form
  101. const lines = [
  102. `# 迁移报告:《${plan.bookName}》`,
  103. '',
  104. `迁移自:${v6Path}(数据形态:${形态};源目录只读未动)`,
  105. `落位:工作目录/${dirName}`,
  106. '',
  107. '## 迁了什么',
  108. '',
  109. ...Object.entries(counts).map(([k, v]) => `- ${k}:${v}`),
  110. '',
  111. '## 待校对(建议按序过一遍)',
  112. '',
  113. ...(待校对.length ? 待校对.map((t, i) => `${i + 1}. ${t}`) : ['(没有需要校对的项)']),
  114. '',
  115. '## 如实丢弃(未迁移)',
  116. '',
  117. ...(丢弃.length ? 丢弃.map((t) => `- ${t}`) : ['(无)']),
  118. '',
  119. '> 校对完成后本报告可删;「迁移待校对-」开头的文件处理完也一并删掉。',
  120. '',
  121. ]
  122. return lines.join('\n')
  123. }
  124. async function sweepStaleTmp(workdir) {
  125. try {
  126. for (const ent of await fs.readdir(workdir)) {
  127. if (ent.startsWith(TMP_PREFIX)) {
  128. await fs.rm(path.join(workdir, ent), { recursive: true, force: true })
  129. }
  130. }
  131. } catch {
  132. // 工作目录读不了让后续步骤报错,这里不拦
  133. }
  134. }
  135. async function exists(p) {
  136. try {
  137. await fs.access(p)
  138. return true
  139. } catch {
  140. return false
  141. }
  142. }