Browse Source

feat(v7): M7 P1——干净导出(单章/范围/全书)

- src/export/index.js:去 front matter 纯正文,落 工作区/导出/(gitignore 天然不入 git)
- 单章无标题行(发布界面标题另填);范围/全书合并单文件,「第N章 标题」行分隔、章间空两行(批量导入可解析)
- 缺章列清单/范围颠倒/空定稿区 人话报错,出错零写入;重复导出覆盖
- export 命令薄壳 + bin --help;全量 414 绿
lingfengQAQ 15 hours ago
parent
commit
5ba85a50f5

+ 1 - 0
v7/bin/webnovel-writer.js

@@ -73,6 +73,7 @@ if (!command || command === '--help') {
   console.log('  impact <关键词>                          影响分析:哪些章建立在这个事实上(已发布/未发布)')
   console.log('  goto-chapter <章号> [--confirm]          回到第N章(先备份再回滚,作者不碰 git)')
   console.log('  relink --message=<一句话说明>            补登手改:定稿/大纲 未登记改动入档(fix(手改))')
+  console.log('  export <章号> | --range=a-b | --all      干净导出:去 front matter 纯正文 → 工作区/导出/')
   console.log('')
   console.log('自动模式(M6,连写按批次定稿):')
   console.log('  stage-chapter <章号> --payload=<json>    暂存一章进待定稿批次(不 commit),返回停止判定')

+ 23 - 0
v7/src/commands/export.js

@@ -0,0 +1,23 @@
+import { exportChapters } from '../export/index.js'
+
+/**
+ * export <章号> | export --range=a-b | export --all
+ * 干净导出:去 front matter 纯正文 → 工作区/导出/(spec §4.7)。
+ */
+export async function run(args, options, ctx) {
+  if (options.all) {
+    return exportChapters(ctx, { mode: 'all' })
+  }
+  if (options.range) {
+    const m = String(options.range).match(/^(\d+)-(\d+)$/)
+    if (!m) {
+      return { ok: false, error: '范围格式应为 --range=起章-止章,例如 --range=6-12。' }
+    }
+    return exportChapters(ctx, { mode: 'range', start: Number(m[1]), end: Number(m[2]) })
+  }
+  const chapterNum = parseInt(args[0], 10)
+  if (Number.isNaN(chapterNum)) {
+    return { ok: false, error: '用法:export <章号>,或 export --range=起章-止章,或 export --all。' }
+  }
+  return exportChapters(ctx, { mode: 'single', chapterNum })
+}

+ 100 - 0
v7/src/export/index.js

@@ -0,0 +1,100 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { ChapterReader } from '../storage/adapters/ChapterReader.js'
+import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
+import { sanitizeFileName } from '../util/filename.js'
+
+/**
+ * 干净导出(spec §4.7):去 front matter 纯正文,落 工作区/导出/(不入 git)。
+ * 单章无标题行(发布界面标题另填);范围/全书合并单文件,每章「第N章 标题」行分隔,
+ * 章间空两行——批量导入工具按标题行分章。
+ * @param {{repoPath: string}} ctx
+ * @param {{mode: 'single'|'range'|'all', chapterNum?: number, start?: number, end?: number}} opts
+ * @returns {Promise<{ok: boolean, files: string[], output: string, error: string}>}
+ */
+export async function exportChapters(ctx, opts) {
+  const existing = await listFinalizedChapters(ctx.repoPath)
+  if (!existing.length) {
+    return fail('还没有定稿章节,没有可导出的正文。')
+  }
+
+  let nums
+  let fileName
+  if (opts.mode === 'single') {
+    nums = [opts.chapterNum]
+  } else if (opts.mode === 'range') {
+    if (opts.start > opts.end) {
+      return fail(`起章不能大于止章(${opts.start} > ${opts.end})。`)
+    }
+    nums = []
+    for (let n = opts.start; n <= opts.end; n++) nums.push(n)
+  } else {
+    nums = existing.map((c) => c.num)
+  }
+
+  const byNum = new Map(existing.map((c) => [c.num, c]))
+  const missing = nums.filter((n) => !byNum.has(n))
+  if (missing.length) {
+    return fail(`第 ${missing.join('、')} 章不在定稿区,无法导出。当前定稿到第 ${existing[existing.length - 1].num} 章。`)
+  }
+
+  const reader = new ChapterReader(ctx.repoPath)
+  const parts = []
+  for (const n of nums) {
+    const body = await reader.readBody(n)
+    if (!body.ok) return fail(`读取第 ${n} 章失败:${body.error}`)
+    parts.push({ num: n, title: byNum.get(n).title, body: body.body.trim() })
+  }
+
+  let content
+  if (opts.mode === 'single') {
+    fileName = `第${pad(nums[0])}章-${sanitizeFileName(parts[0].title)}.txt`
+    content = `${parts[0].body}\n`
+  } else {
+    if (opts.mode === 'range') {
+      fileName = `第${pad(opts.start)}-${pad(opts.end)}章.txt`
+    } else {
+      const config = await new BookConfigReader(ctx.repoPath).read()
+      const 书名 = config.ok ? config.data.书名 : '未命名'
+      fileName = `全书-${sanitizeFileName(书名)}.txt`
+    }
+    content = parts.map((p) => `第${p.num}章 ${p.title}\n\n${p.body}`).join('\n\n\n') + '\n'
+  }
+
+  const outDir = path.join(ctx.repoPath, '工作区', '导出')
+  await fs.mkdir(outDir, { recursive: true })
+  await fs.writeFile(path.join(outDir, fileName), content, 'utf8')
+
+  const 章数 = nums.length
+  return {
+    ok: true,
+    files: [fileName],
+    output: `已导出 ${章数} 章到 工作区/导出/${fileName}(纯正文,可直接粘贴发布)。`,
+    error: '',
+  }
+}
+
+/** 扫定稿/正文/ 列全部章(升序),文件名 NNNN-标题.md。 */
+async function listFinalizedChapters(repoPath) {
+  const dir = path.join(repoPath, '定稿', '正文')
+  let files
+  try {
+    files = await fs.readdir(dir)
+  } catch {
+    return []
+  }
+  const chapters = []
+  for (const f of files) {
+    const m = f.match(/^(\d{4})-(.+)\.md$/)
+    if (m) chapters.push({ num: Number(m[1]), title: m[2] })
+  }
+  return chapters.sort((a, b) => a.num - b.num)
+}
+
+function pad(n) {
+  return String(n).padStart(4, '0')
+}
+
+function fail(error) {
+  return { ok: false, files: [], output: '', error }
+}

+ 31 - 0
v7/test/commands/export.test.js

@@ -0,0 +1,31 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/export.js'
+import { tempBookCtx } from './_helper.js'
+
+test('export 命令:单章/范围/全书三形态与用法提示', async () => {
+  const { ctx, cleanup } = await tempBookCtx()
+  try {
+    const single = await run(['1'], {}, ctx)
+    assert.equal(single.ok, true, single.error)
+    assert.match(single.output, /工作区\/导出\/第0001章-开局\.txt/)
+
+    const range = await run([], { range: '1-2' }, ctx)
+    assert.equal(range.ok, true, range.error)
+    assert.match(range.output, /已导出 2 章/)
+
+    const all = await run([], { all: true }, ctx)
+    assert.equal(all.ok, true, all.error)
+    assert.match(all.output, /全书-测试书\.txt/)
+
+    const noArgs = await run([], {}, ctx)
+    assert.equal(noArgs.ok, false)
+    assert.match(noArgs.error, /用法/)
+
+    const badRange = await run([], { range: 'a-b' }, ctx)
+    assert.equal(badRange.ok, false)
+    assert.match(badRange.error, /范围格式/)
+  } finally {
+    await cleanup()
+  }
+})

+ 103 - 0
v7/test/export/index.test.js

@@ -0,0 +1,103 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import { exportChapters } from '../../src/export/index.js'
+import { ChapterReader } from '../../src/storage/adapters/ChapterReader.js'
+import { tempBookCtx, repoCtx } from '../commands/_helper.js'
+
+// sample-book:第 1 章「开局」、第 2 章「初遇」,书名「测试书」
+
+async function readExport(ctx, name) {
+  return fs.readFile(path.join(ctx.repoPath, '工作区', '导出', name), 'utf8')
+}
+
+test('单章导出:去 front matter 纯正文、无标题行、文件名零填充', async () => {
+  const { ctx, cleanup } = await tempBookCtx()
+  try {
+    const r = await exportChapters(ctx, { mode: 'single', chapterNum: 1 })
+    assert.equal(r.ok, true, r.error)
+    assert.deepEqual(r.files, ['第0001章-开局.txt'])
+    const body = (await new ChapterReader(ctx.repoPath).readBody(1)).body
+    const exported = await readExport(ctx, '第0001章-开局.txt')
+    assert.equal(exported, body.trim() + '\n')
+    assert.doesNotMatch(exported, /^第1章/) // 正文体不带标题行
+    assert.doesNotMatch(exported, /^---/) // 无 front matter
+  } finally {
+    await cleanup()
+  }
+})
+
+test('范围导出:合并单文件、每章「第N章 标题」行、章间空两行', async () => {
+  const { ctx, cleanup } = await tempBookCtx()
+  try {
+    const r = await exportChapters(ctx, { mode: 'range', start: 1, end: 2 })
+    assert.equal(r.ok, true, r.error)
+    assert.deepEqual(r.files, ['第0001-0002章.txt'])
+    const reader = new ChapterReader(ctx.repoPath)
+    const b1 = (await reader.readBody(1)).body.trim()
+    const b2 = (await reader.readBody(2)).body.trim()
+    const exported = await readExport(ctx, '第0001-0002章.txt')
+    assert.equal(exported, `第1章 开局\n\n${b1}\n\n\n第2章 初遇\n\n${b2}\n`)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('全书导出:文件名含书名、内容同合并格式;重复导出覆盖', async () => {
+  const { ctx, cleanup } = await tempBookCtx()
+  try {
+    const r1 = await exportChapters(ctx, { mode: 'all' })
+    assert.equal(r1.ok, true, r1.error)
+    assert.deepEqual(r1.files, ['全书-测试书.txt'])
+    const first = await readExport(ctx, '全书-测试书.txt')
+    assert.match(first, /^第1章 开局\n/)
+    assert.match(first, /\n第2章 初遇\n/)
+
+    const r2 = await exportChapters(ctx, { mode: 'all' })
+    assert.equal(r2.ok, true, r2.error)
+    assert.equal(await readExport(ctx, '全书-测试书.txt'), first) // 覆盖后仍完整
+  } finally {
+    await cleanup()
+  }
+})
+
+test('缺章:范围含空洞人话列缺章,零写入', async () => {
+  const { ctx, cleanup } = await tempBookCtx()
+  try {
+    const r = await exportChapters(ctx, { mode: 'range', start: 1, end: 4 })
+    assert.equal(r.ok, false)
+    assert.match(r.error, /第 3、4 章/)
+    await assert.rejects(readExport(ctx, '第0001-0004章.txt'))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('单章不存在与范围颠倒:人话报错', async () => {
+  const { ctx, cleanup } = await tempBookCtx()
+  try {
+    const miss = await exportChapters(ctx, { mode: 'single', chapterNum: 9 })
+    assert.equal(miss.ok, false)
+    assert.match(miss.error, /第 9 章/)
+
+    const flipped = await exportChapters(ctx, { mode: 'range', start: 2, end: 1 })
+    assert.equal(flipped.ok, false)
+    assert.match(flipped.error, /起章不能大于止章/)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('空定稿区:人话报错', async () => {
+  const { ctx, cleanup } = await repoCtx(null, {
+    'book.yaml': 'spec_version: "7.0"\n书名: 空书\n类型: 玄幻\n每章目标字数: 3000\n卷规模: 40\n',
+  })
+  try {
+    const r = await exportChapters(ctx, { mode: 'all' })
+    assert.equal(r.ok, false)
+    assert.match(r.error, /还没有定稿章节/)
+  } finally {
+    await cleanup()
+  }
+})