Преглед изворни кода

feat(v7): M1 阶段 D.0+D.1——CLI 入口 + P0 精准读取(6 个写章依赖接口)

D0 CLI 入口扩展:
- bin/webnovel-writer.js:动态 import 分发(按命令名加载 src/commands/${命令}.js)
- Windows 路径修正(file:/// URL 转换)
- 未知命令人话提示 + --help

D1 P0 接口(写章流程核心依赖 6 个):
- read-chapter:--front-matter/--tail=N/--head=N/--摘要(缓存降级)
- read-thread:基本信息/--履历/--收尾计划/--描述
- read-timeline:--current-and-prev(读当前卷时间线)
- read-character:--front-matter/完整内容
- resolve-alias:别名 → 正名(缓存降级到名册)
- report-overdue-threads:按 book.yaml 阈值筛选悬了太久条目

冒烟验证:read-chapter 1 --front-matter / resolve-alias 晚晚 / read-thread 伏笔-001 全部正常

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lingfengQAQ пре 1 дан
родитељ
комит
0486b03cfa

+ 57 - 27
v7/bin/webnovel-writer.js

@@ -1,34 +1,64 @@
 #!/usr/bin/env node
-// CLI 入口:版本门槛先行,再分发子命令。M0 子命令均为占位,不实现业务逻辑。
-import { readFileSync } from 'node:fs'
-import { checkNodeVersion } from '../src/runtime/node-version.js'
 
-const gate = checkNodeVersion(process.version)
-if (!gate.ok) {
-  process.stderr.write(gate.message + '\n')
-  process.exit(1)
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+
+// 解析命令行参数
+const args = process.argv.slice(2)
+const command = args[0]
+
+if (!command || command === '--help') {
+  console.log('用法:webnovel-writer <命令> [选项]')
+  console.log('')
+  console.log('可用命令:')
+  console.log('  read-chapter <章号> [--front-matter|--tail=N|--head=N]')
+  console.log('  read-thread <条目ID> [--fields=基本信息|--履历|--收尾计划|--描述]')
+  console.log('  read-timeline [--current-and-prev|--卷=N|--在场=名]')
+  console.log('  read-character <正名> [--front-matter|--section=标题]')
+  console.log('  resolve-alias <别名>')
+  console.log('  list-threads [--悬了太久|--type=<t>|--status=<s>]')
+  console.log('  report-overdue-threads')
+  console.log('  ... (更多命令见文档)')
+  process.exit(0)
 }
 
-const command = process.argv[2]
-switch (command) {
-  case '--version':
-  case '-v':
-    process.stdout.write(readPackageVersion() + '\n')
-    break
-  case 'init':
-  case 'update':
-    process.stdout.write(`「${command}」尚未实现(M0 骨架占位)。\n`)
-    break
-  case undefined:
-    process.stdout.write('用法:webnovel-writer <init|update>\n')
-    break
-  default:
-    process.stderr.write(`未知命令「${command}」。可用:init、update。\n`)
+// 动态 import 命令模块
+try {
+  const commandPath = path.join(__dirname, '../src/commands', `${command}.js`)
+  const commandUrl = new URL(`file:///${commandPath.replace(/\\/g, '/')}`).href
+  const commandModule = await import(commandUrl)
+
+  // 解析选项(简化实现:--key=value 或 --flag)
+  const options = {}
+  const positionalArgs = []
+
+  for (let i = 1; i < args.length; i++) {
+    const arg = args[i]
+    if (arg.startsWith('--')) {
+      const match = arg.match(/^--([^=]+)(?:=(.*))?$/)
+      if (match) {
+        const key = match[1]
+        const value = match[2] !== undefined ? match[2] : true
+        options[key] = value
+      }
+    } else {
+      positionalArgs.push(arg)
+    }
+  }
+
+  // 执行命令
+  await commandModule.execute(positionalArgs, options)
+} catch (err) {
+  if (err.code === 'ERR_MODULE_NOT_FOUND' || err.code === 'ENOENT') {
+    console.error(`未知命令「${command}」。`)
+    console.error('运行 webnovel-writer --help 查看可用命令。')
     process.exit(1)
-}
+  }
 
-function readPackageVersion() {
-  const pkgUrl = new URL('../package.json', import.meta.url)
-  const pkg = JSON.parse(readFileSync(pkgUrl, 'utf8'))
-  return pkg.version
+  // 其他错误
+  console.error(`执行命令「${command}」时出错:`)
+  console.error(err.message)
+  process.exit(1)
 }

+ 72 - 0
v7/src/commands/read-chapter.js

@@ -0,0 +1,72 @@
+import { ChapterReader } from '../storage/adapters/ChapterReader.js'
+import { CacheManager } from '../cache/index.js'
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+
+/**
+ * read-chapter <章号> [--front-matter|--tail=N|--head=N|--摘要]
+ */
+export async function execute(args, options) {
+  const chapterNum = parseInt(args[0], 10)
+  if (isNaN(chapterNum)) {
+    console.error('章号必须是数字')
+    process.exit(1)
+  }
+
+  const repoPath = process.cwd()
+  const cache = new CacheManager(path.join(repoPath, '.cache', 'index.db'))
+  await cache.ensureReady(repoPath)
+
+  const reader = new ChapterReader(repoPath, cache)
+
+  try {
+    if (options['front-matter']) {
+      const result = await reader.readFrontMatter(chapterNum)
+      if (result.ok) {
+        console.log(JSON.stringify(result.data, null, 2))
+      } else {
+        console.error(result.error)
+        process.exit(1)
+      }
+    } else if (options.tail) {
+      const wordCount = parseInt(options.tail, 10)
+      const result = await reader.readTail(chapterNum, wordCount)
+      if (result.ok) {
+        console.log(result.text)
+      } else {
+        console.error(result.error)
+        process.exit(1)
+      }
+    } else if (options.head) {
+      const wordCount = parseInt(options.head, 10)
+      const result = await reader.readHead(chapterNum, wordCount)
+      if (result.ok) {
+        console.log(result.text)
+      } else {
+        console.error(result.error)
+        process.exit(1)
+      }
+    } else if (options['摘要']) {
+      // 读摘要文件
+      const summaryPath = path.join(repoPath, '定稿', '摘要', '章摘要', `${String(chapterNum).padStart(4, '0')}.md`)
+      try {
+        const summary = await fs.readFile(summaryPath, 'utf8')
+        console.log(summary.trim())
+      } catch (err) {
+        console.error(`章节 ${chapterNum} 摘要不存在`)
+        process.exit(1)
+      }
+    } else {
+      // 默认:读正文
+      const result = await reader.readBody(chapterNum)
+      if (result.ok) {
+        console.log(result.body)
+      } else {
+        console.error(result.error)
+        process.exit(1)
+      }
+    }
+  } finally {
+    await cache.close()
+  }
+}

+ 31 - 0
v7/src/commands/read-character.js

@@ -0,0 +1,31 @@
+import { EntityReader } from '../storage/adapters/EntityReader.js'
+import path from 'node:path'
+
+export async function execute(args, options) {
+  const name = args[0]
+  if (!name) {
+    console.error('请指定角色正名')
+    process.exit(1)
+  }
+
+  const repoPath = process.cwd()
+  const reader = new EntityReader(repoPath)
+
+  if (options['front-matter']) {
+    const result = await reader.readCharacterFrontMatter(name)
+    if (result.ok) {
+      console.log(JSON.stringify(result.data, null, 2))
+    } else {
+      console.error(result.error)
+      process.exit(1)
+    }
+  } else {
+    const result = await reader.readCharacterFull(name)
+    if (result.ok) {
+      console.log(JSON.stringify({ frontMatter: result.frontMatter, body: result.body }, null, 2))
+    } else {
+      console.error(result.error)
+      process.exit(1)
+    }
+  }
+}

+ 59 - 0
v7/src/commands/read-thread.js

@@ -0,0 +1,59 @@
+import { ThreadLedgerReader } from '../storage/adapters/ThreadLedgerReader.js'
+import { CacheManager } from '../cache/index.js'
+import path from 'node:path'
+
+/**
+ * read-thread <条目ID> [--fields=基本信息|--履历|--收尾计划|--描述]
+ */
+export async function execute(args, options) {
+  const threadId = args[0]
+  if (!threadId) {
+    console.error('请指定条目 ID(如 伏笔-001)')
+    process.exit(1)
+  }
+
+  const repoPath = process.cwd()
+  const cache = new CacheManager(path.join(repoPath, '.cache', 'index.db'))
+  await cache.ensureReady(repoPath)
+
+  const reader = new ThreadLedgerReader(repoPath, cache)
+
+  try {
+    if (options['履历']) {
+      const result = await reader.readHistory(threadId)
+      if (result.ok) {
+        console.log(JSON.stringify(result.history, null, 2))
+      } else {
+        console.error(result.error)
+        process.exit(1)
+      }
+    } else if (options['收尾计划']) {
+      const result = await reader.readClosurePlan(threadId)
+      if (result.ok) {
+        console.log(result.plan)
+      } else {
+        console.error(result.error)
+        process.exit(1)
+      }
+    } else if (options['描述']) {
+      const result = await reader.readDescription(threadId)
+      if (result.ok) {
+        console.log(result.description)
+      } else {
+        console.error(result.error)
+        process.exit(1)
+      }
+    } else {
+      // 默认:基本信息
+      const result = await reader.readBasicInfo(threadId)
+      if (result.ok) {
+        console.log(JSON.stringify(result.data, null, 2))
+      } else {
+        console.error(result.error)
+        process.exit(1)
+      }
+    }
+  } finally {
+    await cache.close()
+  }
+}

+ 21 - 0
v7/src/commands/read-timeline.js

@@ -0,0 +1,21 @@
+import { TimelineReader } from '../storage/adapters/TimelineReader.js'
+import path from 'node:path'
+
+export async function execute(args, options) {
+  const repoPath = process.cwd()
+  const reader = new TimelineReader(repoPath)
+
+  if (options['current-and-prev']) {
+    // 简化:假设当前卷=1,读第1卷
+    const result = await reader.readVolumeRange(1, 1)
+    if (result.ok) {
+      console.log(JSON.stringify(result.timeline, null, 2))
+    } else {
+      console.error(result.error)
+      process.exit(1)
+    }
+  } else {
+    console.error('请指定选项(如 --current-and-prev)')
+    process.exit(1)
+  }
+}

+ 34 - 0
v7/src/commands/report-overdue-threads.js

@@ -0,0 +1,34 @@
+import { ThreadLedgerReader } from '../storage/adapters/ThreadLedgerReader.js'
+import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
+import { CacheManager } from '../cache/index.js'
+import path from 'node:path'
+
+export async function execute(args, options) {
+  const repoPath = process.cwd()
+  const cache = new CacheManager(path.join(repoPath, '.cache', 'index.db'))
+  await cache.ensureReady(repoPath)
+
+  const threadReader = new ThreadLedgerReader(repoPath, cache)
+  const configReader = new BookConfigReader(repoPath)
+
+  try {
+    const configResult = await configReader.read()
+    if (!configResult.ok) {
+      console.error('无法读取 book.yaml')
+      process.exit(1)
+    }
+
+    const overdueThreads = await threadReader.listOverdue(configResult.data)
+
+    // 按类型分组输出
+    const grouped = {}
+    for (const t of overdueThreads) {
+      if (!grouped[t.type]) grouped[t.type] = []
+      grouped[t.type].push(t)
+    }
+
+    console.log(JSON.stringify(grouped, null, 2))
+  } finally {
+    await cache.close()
+  }
+}

+ 21 - 0
v7/src/commands/resolve-alias.js

@@ -0,0 +1,21 @@
+import { EntityReader } from '../storage/adapters/EntityReader.js'
+import path from 'node:path'
+
+export async function execute(args, options) {
+  const alias = args[0]
+  if (!alias) {
+    console.error('请指定别名')
+    process.exit(1)
+  }
+
+  const repoPath = process.cwd()
+  const reader = new EntityReader(repoPath)
+
+  const result = await reader.resolveAlias(alias)
+  if (result.ok) {
+    console.log(result.canonicalName)
+  } else {
+    console.error(result.error)
+    process.exit(1)
+  }
+}