ChapterWriter.js 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. import { promises as fs } from 'node:fs'
  2. import path from 'node:path'
  3. import { serializeFrontMatter } from '../serializers/front-matter.js'
  4. /**
  5. * ChapterWriter:写新章到定稿(M2 定稿流程调用)。
  6. */
  7. let backupCounter = 0
  8. /** 文件名净化:Windows 非法字符 <>:"/\|?* 与控制字符替成 _(标题本体不改,只净化文件名)。 */
  9. function sanitizeFileName(title) {
  10. const s = String(title).replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/\s+/g, ' ').trim()
  11. return s || '未命名'
  12. }
  13. /** 临时挪走同章旧文件(标题可能不同),避免 scanChapters 撞 PRIMARY KEY(P0-3a)。 */
  14. async function backupOldChapterFiles(dir, chapterNum, safeTitle) {
  15. const prefix = `${String(chapterNum).padStart(4, '0')}-`
  16. const target = `${prefix}${safeTitle}.md`
  17. let files = []
  18. try {
  19. files = await fs.readdir(dir)
  20. } catch {
  21. return
  22. }
  23. const backups = []
  24. for (const f of files) {
  25. if (!f.startsWith(prefix) || !f.endsWith('.md')) continue
  26. if (f === target) continue
  27. const original = path.join(dir, f)
  28. const backup = path.join(dir, `${f}.wnwbackup.${process.pid}.${backupCounter++}`)
  29. await fs.rename(original, backup)
  30. backups.push({ original, backup })
  31. }
  32. return backups
  33. }
  34. async function restoreBackups(backups) {
  35. for (const b of backups.toReversed()) {
  36. try {
  37. await fs.rm(b.original, { force: true })
  38. await fs.rename(b.backup, b.original)
  39. } catch {
  40. // 尽力恢复;调用方会拿到原始错误
  41. }
  42. }
  43. }
  44. async function discardBackups(backups) {
  45. for (const b of backups) {
  46. await fs.rm(b.backup, { force: true })
  47. }
  48. }
  49. export class ChapterWriter {
  50. constructor(repoPath, cache = null) {
  51. this.repoPath = repoPath
  52. this.cache = cache
  53. }
  54. /**
  55. * 写新章到 定稿/正文/NNNN-标题.md(front matter 走防呆序列化)。
  56. * @param {number} chapterNum
  57. * @param {object} frontMatter - 章档案(章号/标题/卷/视角/字数/章定位/钩子/情绪定位/伏笔[]/...)
  58. * @param {string} body - 正文(不含 front matter)
  59. * @param {{preserveBackups?: boolean}} [opts] finalize 需要保留备份到 commit 成功后,供失败回滚
  60. * @returns {Promise<{ok: boolean, filePath: string, error: string}>}
  61. */
  62. async writeChapter(chapterNum, frontMatter, body, opts = {}) {
  63. const backups = []
  64. try {
  65. const title = frontMatter.标题 || '未命名'
  66. const safeTitle = sanitizeFileName(title)
  67. const dir = path.join(this.repoPath, '定稿', '正文')
  68. await fs.mkdir(dir, { recursive: true })
  69. backups.push(...(await backupOldChapterFiles(dir, chapterNum, safeTitle)))
  70. const fileName = `${String(chapterNum).padStart(4, '0')}-${safeTitle}.md`
  71. const filePath = path.join(dir, fileName)
  72. await fs.writeFile(filePath, serializeFrontMatter(frontMatter, body), 'utf8')
  73. if (!opts.preserveBackups) await discardBackups(backups)
  74. return {
  75. ok: true,
  76. filePath,
  77. removedPaths: backups.map((b) => b.original),
  78. backups,
  79. error: '',
  80. }
  81. } catch (err) {
  82. await restoreBackups(backups)
  83. return { ok: false, filePath: '', error: `写章节 ${chapterNum} 失败:${err.message}` }
  84. }
  85. }
  86. /**
  87. * 更新已有章节 front matter(M2 写新章流程不需要,按需补)。
  88. */
  89. async updateFrontMatter(chapterNum, updates) {
  90. throw new Error('ChapterWriter.updateFrontMatter() 暂未实现(M2 写新章不依赖;按需补)')
  91. }
  92. }