Files
bilin/docs/superpowers/plans/2026-07-08-bilin-v11-writer-toolkit.md
bing adf877861d feat: ship v1.1.0 writer toolkit with templates, import, export, and inspiration capture
Implement chapter templates, txt/md/docx import, submission export presets, and global inspiration shortcut. Split import handlers into a separate main bundle and fix EditorLayout setTarget loop that broke E2E.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 13:53:22 +08:00

20 KiB
Raw Permalink Blame History

笔临 v1.1 作家日更工具包 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [x]) syntax for tracking.

Goal: 交付 v1.1.0:§10 章节模板 + 快速新建、§11.1 txt/md/docx 导入、§20.2 投稿预设单章导出、§20.6 全局灵感捕获增强。

Architecture: GlobalSettings 扩展 chapterTemplates / submissionPresets;渲染进程 chapter-template.ts 做变量替换后复用 chapter.create/update;主进程 ImportServicemammoth/marked + chapter-splitter)与 ExportServicesubmission-formatter + clipboard);灵感模态解除视图限制且保存不跳转。

Tech Stack: Electron 36、electron-vite、React 18、TypeScript、node:sqlite、mammoth、marked、Zustand、i18next、Vitest、Playwright

Spec: docs/superpowers/specs/2026-07-08-bilin-v11-writer-toolkit-design.md


File Map

文件 职责
src/shared/types.ts ChapterTemplateSubmissionPresetImport*Export* DTO
src/shared/builtin-templates.ts 内置模板与投稿预设常量
src/shared/ipc-channels.ts import.* / export.* / IMPORT_PROGRESS
src/main/lib/chapter-splitter.ts txt/md/html 分章
src/main/lib/submission-formatter.ts HTML → 投稿纯文本
src/main/services/import.service.ts 读文件、预览、创建书+章
src/main/services/export.service.ts 格式化章节、剪贴板、存盘
src/main/ipc/handlers/import.handler.ts 导入 IPC + progress 事件
src/main/ipc/handlers/export.handler.ts 导出 IPC
src/main/ipc/register.ts 注册 import/export
src/preload/index.ts + electron-api.d.ts import / export API
src/renderer/lib/chapter-template.ts 模板变量替换
src/renderer/components/chapter/TemplateQuickCreateModal.tsx 快速新建章
src/renderer/components/settings/TemplateSettingsTab.tsx 模板 CRUD
src/renderer/components/settings/SubmissionPresetTab.tsx 投稿预设 CRUD
src/renderer/components/import/ImportBookModal.tsx 导入 UI
src/renderer/components/export/ExportModal.tsx 导出 UI
src/renderer/components/inspiration/InspirationModal.tsx 全局灵感增强
src/renderer/components/home/HomePage.tsx 导入入口
src/renderer/components/layout/EditorLayout.tsx 快速新建/导出入口
src/renderer/App.tsx 全局灵感快捷键
src/renderer/stores/useSettingsStore.ts 新 settings 字段默认值
src/main/services/global-settings.ts defaults 合并
public/locales/*/translation.json template / import / export / submission / inspiration
src/renderer/styles/layout.css 模板/导入/导出样式
tests/main/chapter-template.test.ts UT-TEMPL-*
tests/main/chapter-splitter.test.ts UT-IMPORT-01
tests/main/submission-formatter.test.ts UT-EXPORT-*
tests/main/import.service.test.ts IT-IMPORT-01
e2e/chapter-template.spec.ts E2E-TEMPL-01
e2e/import-book.spec.ts E2E-IMPORT-01
e2e/export-submission.spec.ts E2E-EXPORT-01
e2e/inspiration-capture.spec.ts E2E-INSP-01

Task 1: 类型 + 内置常量 + GlobalSettings

Files:

  • Modify: src/shared/types.ts

  • Create: src/shared/builtin-templates.ts

  • Modify: src/main/services/global-settings.ts

  • Modify: src/renderer/stores/useSettingsStore.ts

  • Step 1: 扩展 types

export interface ChapterTemplate {
  id: string
  name: string
  body: string
  defaultStatus?: ChapterStatus
  builtin?: boolean
}

export interface SubmissionPreset {
  id: string
  name: string
  titleFormat: string
  indentParagraphs: boolean
  includeAuthorsNote: boolean
  paragraphGap: 'single' | 'double'
}

export type ImportSplitMode = 'auto' | 'paragraph' | 'regex'

export interface ImportChapterPreview {
  title: string
  contentHtml: string
  wordCount: number
}

export interface ImportPreviewResult {
  fileName: string
  fileSizeBytes: number
  chapters: ImportChapterPreview[]
  warnings: string[]
}

export interface ImportExecuteParams {
  filePath: string
  bookName: string
  category: string
  splitMode: ImportSplitMode
  customRegex?: string
}

// GlobalSettings 追加:
chapterTemplates?: ChapterTemplate[]
submissionPresets?: SubmissionPreset[]
defaultSubmissionPresetId?: string
  • Step 2: builtin-templates.ts
import type { ChapterTemplate, SubmissionPreset } from './types'

export const BUILTIN_CHAPTER_TEMPLATES: ChapterTemplate[] = [
  {
    id: 'builtin-generic',
    name: '通用模板',
    body: '## 第{chapterNumber}章 {chapterTitle}\n\n',
    builtin: true
  },
  {
    id: 'builtin-webnovel',
    name: '网文常用',
    body: '## 第{chapterNumber}章 {chapterTitle}\n\n\n\n——\n作者的话:\n',
    builtin: true
  }
]

export const BUILTIN_SUBMISSION_PRESETS: SubmissionPreset[] = [
  {
    id: 'builtin-webnovel',
    name: '通用网文',
    titleFormat: '# 第{chapterNumber}章 {chapterTitle}',
    indentParagraphs: true,
    includeAuthorsNote: true,
    paragraphGap: 'double'
  }
]

export function mergeChapterTemplates(custom: ChapterTemplate[] = []): ChapterTemplate[] {
  return [...BUILTIN_CHAPTER_TEMPLATES, ...custom.filter((t) => !t.builtin)]
}

export function mergeSubmissionPresets(custom: SubmissionPreset[] = []): SubmissionPreset[] {
  return [...BUILTIN_SUBMISSION_PRESETS, ...custom.filter((p) => p.id !== 'builtin-webnovel')]
}
  • Step 3: global-settings defaults

defaults() 追加 chapterTemplates: [], submissionPresets: [], defaultSubmissionPresetId: 'builtin-webnovel'

  • Step 4: useSettingsStore 初始字段对齐

  • Step 5: build

npm run build

Task 2: chapter-template 引擎 + 单元测试

Files:

  • Create: src/renderer/lib/chapter-template.ts

  • Create: tests/main/chapter-template.test.ts

  • Step 1: 实现 replaceChapterTemplate

export interface TemplateContext {
  chapterNumber: number
  chapterTitle: string
  penName: string
  date: string
  volumeName: string
  previousChapterTitle: string
  outlineItem: string
  characterList: string
}

const VARS = [
  'chapterNumber', 'chapterTitle', 'penName', 'date',
  'volumeName', 'previousChapterTitle', 'outlineItem', 'characterList'
] as const

export function replaceChapterTemplate(body: string, ctx: TemplateContext): string {
  let out = body
  for (const key of VARS) {
    out = out.split(`{${key}}`).join(String(ctx[key]))
  }
  return out
}

export function plainTextToEditorHtml(text: string): string {
  return text
    .split(/\n{2,}/)
    .map((p) => `<p>${p.replace(/\n/g, '<br>')}</p>`)
    .join('')
}
  • Step 2: UT-TEMPL-01~03
it('UT-TEMPL-01: replaces chapterNumber and chapterTitle', () => {
  const result = replaceChapterTemplate('第{chapterNumber}章 {chapterTitle}', {
    chapterNumber: 5, chapterTitle: '觉醒',
    penName: '', date: '', volumeName: '', previousChapterTitle: '',
    outlineItem: '', characterList: ''
  })
  expect(result).toBe('第5章 觉醒')
})
  • Step 3: 运行
npm run test -- tests/main/chapter-template.test.ts

Task 3: TemplateQuickCreateModal + 章节侧栏入口

Files:

  • Create: src/renderer/components/chapter/TemplateQuickCreateModal.tsx

  • Modify: src/renderer/components/layout/EditorLayout.tsx

  • Step 1: TemplateQuickCreateModal

  • data-testid="chapter-quick-new" 打开模态

  • useSettingsStore + mergeChapterTemplates 展示模板卡片

  • 创建流程:chapter.createreplaceChapterTemplateplainTextToEditorHtmlchapter.update({ content })

  • 若模板 defaultStatus 设置则 chapter.update({ status })

  • switchTarget 到新章

  • Step 2: EditorLayout

在章节面板底部保留原「+ 新章」按钮,新增「模板新建」按钮 data-testid="chapter-quick-new"

  • Step 3: build
npm run build

Task 4: 设置页章节模板 Tab

Files:

  • Create: src/renderer/components/settings/TemplateSettingsTab.tsx

  • Modify: src/renderer/components/settings/SettingsPage.tsx

  • Step 1: TemplateSettingsTab

  • data-testid="settings-chapter-templates"

  • 内置模板只读展示

  • 自定义:setting-template-add 新建;编辑 name/body;删除(uuid id

  • 保存:settings.update({ chapterTemplates: customOnly })

  • Step 2: SettingsPage 导航

新增 section 'templates',侧栏项 settings.templates

  • Step 3: build
npm run build

Task 5: submission-formatter + 单元测试

Files:

  • Create: src/main/lib/submission-formatter.ts

  • Create: tests/main/submission-formatter.test.ts

  • Step 1: 实现

import { stripHtml } from '../services/word-count'
import type { SubmissionPreset } from '../../shared/types'

export interface FormatChapterInput {
  chapterNumber: number
  chapterTitle: string
  contentHtml: string
  preset: SubmissionPreset
}

export function formatChapterForSubmission(input: FormatChapterInput): string {
  let body = stripHtml(input.contentHtml)
  if (!input.preset.includeAuthorsNote) {
    body = body.split(/\n——\n/)[0] ?? body
  }
  const paragraphs = body.split(/\n+/).filter((p) => p.trim())
  const indented = input.preset.indentParagraphs
    ? paragraphs.map((p) => `  ${p.trim()}`)
    : paragraphs.map((p) => p.trim())
  const gap = input.preset.paragraphGap === 'double' ? '\n\n' : '\n'
  const title = input.preset.titleFormat
    .replace('{chapterNumber}', String(input.chapterNumber))
    .replace('{chapterTitle}', input.chapterTitle)
  return `${title}\n\n${indented.join(gap)}`
}
  • Step 2: UT-EXPORT-01 / UT-EXPORT-02

  • Step 3: 运行

npm run test -- tests/main/submission-formatter.test.ts

Task 6: ExportService + IPC + preload

Files:

  • Create: src/main/services/export.service.ts

  • Create: src/main/ipc/handlers/export.handler.ts

  • Modify: src/shared/ipc-channels.ts

  • Modify: src/main/ipc/register.ts

  • Modify: src/preload/index.ts

  • Modify: src/shared/electron-api.d.ts

  • Step 1: ExportService

export class ExportService {
  constructor(
    private registry: BookRegistryService,
    private settings: GlobalSettingsService
  ) {}

  formatChapter(bookId: string, chapterId: string, presetId: string): string {
    const db = this.registry.getDb(bookId)
    const chapter = new ChapterRepository(db).get(chapterId)
    if (!chapter) throw new Error('Chapter not found')
    const presets = mergeSubmissionPresets(this.settings.get().submissionPresets)
    const preset = presets.find((p) => p.id === presetId) ?? BUILTIN_SUBMISSION_PRESETS[0]
    const chapters = new ChapterRepository(db).list().filter((c) => c.volumeId === chapter.volumeId)
    const chapterNumber = chapters.findIndex((c) => c.id === chapterId) + 1
    return formatChapterForSubmission({
      chapterNumber, chapterTitle: chapter.title,
      contentHtml: chapter.content, preset
    })
  }

  copyToClipboard(text: string): void {
    clipboard.writeText(text)
  }

  async saveTxt(text: string, defaultName: string): Promise<string | null> {
    const { canceled, filePath } = await dialog.showSaveDialog({
      defaultPath: defaultName,
      filters: [{ name: 'Text', extensions: ['txt'] }]
    })
    if (canceled || !filePath) return null
    writeFileSync(filePath, text, 'utf8')
    return filePath
  }
}
  • Step 2: export.handler + register + preload
EXPORT_FORMAT_CHAPTER: 'export:formatChapter',
EXPORT_COPY_CLIPBOARD: 'export:copyClipboard',
EXPORT_SAVE_TXT: 'export:saveTxt',
  • Step 3: build
npm run build

Task 7: ExportModal + 投稿预设设置 Tab + 快捷键

Files:

  • Create: src/renderer/components/export/ExportModal.tsx

  • Create: src/renderer/components/settings/SubmissionPresetTab.tsx

  • Modify: src/renderer/components/settings/SettingsPage.tsx

  • Modify: src/renderer/components/layout/EditorLayout.tsx

  • Modify: src/renderer/App.tsx

  • Step 1: ExportModal

  • data-testid="export-modal"

  • export-preset-select 下拉(mergeSubmissionPresets

  • 预览区:调用 export.formatChapter 显示前 500 字

  • export-copy-clipboard / export-save-txt

  • Step 2: SubmissionPresetTab

  • data-testid="settings-submission-presets"

  • CRUD 自定义预设(不可删 builtin-webnovel

  • Step 3: EditorLayout 工具栏 + App exportBook 快捷键

if (action === 'exportBook') {
  if (view !== 'editor' || !chapterId) return
  useExportStore.getState().openModal()
  return
}

创建 useExportStoreopen/close,类似 useGraphStore)。

  • Step 4: build
npm run build

Task 8: InspirationModal 全局增强

Files:

  • Modify: src/renderer/components/inspiration/InspirationModal.tsx

  • Modify: src/renderer/App.tsx

  • Modify: src/renderer/styles/layout.css

  • Step 1: App.tsx 移除 view 限制

if (action === 'captureInspiration') {
  useAppStore.getState().setInspirationModalOpen(true)
  return
}
  • Step 2: InspirationModal

  • bookId 时显示 inspiration-book-selectuseBookStore.bookslastOpenedAt 排序)

  • handleSave 删除 switchTarget / setSidebarPanel;改为 showToast(t('inspiration.saved'))

  • 添加 inspiration-modal--capture class

  • Step 3: E2E 预备 build

npm run build

Task 9: chapter-splitter + 单元测试

Files:

  • Create: src/main/lib/chapter-splitter.ts

  • Create: tests/main/chapter-splitter.test.ts

  • Step 1: splitPlainText

export function splitPlainText(
  text: string,
  mode: ImportSplitMode,
  customRegex?: string
): Array<{ title: string; body: string }> {
  // auto: 尝试 MARKDOWN_HEADING 再 CHINESE_CHAPTER
  // paragraph: split \n\s*\n
  // regex: new RegExp(customRegex, 'gm') 分行匹配
}
  • Step 2: UT-IMPORT-01 — 样本文本 3 章标题

  • Step 3: 运行

npm run test -- tests/main/chapter-splitter.test.ts

Task 10: ImportService + IPC + 依赖安装

Files:

  • Create: src/main/services/import.service.ts

  • Create: src/main/ipc/handlers/import.handler.ts

  • Modify: package.json

  • Modify: src/main/ipc/register.ts

  • Modify: src/preload/index.ts

  • Create: tests/main/import.service.test.ts

  • Step 1: 安装依赖

npm install mammoth marked --legacy-peer-deps
npm install -D @types/marked --legacy-peer-deps
  • Step 2: ImportService
export class ImportService {
  async pickFile(): Promise<{ canceled: boolean; filePath?: string }> { /* dialog */ }

  async preview(filePath: string, splitMode: ImportSplitMode, customRegex?: string): Promise<ImportPreviewResult> {
    const stat = statSync(filePath)
    const ext = extname(filePath).toLowerCase()
    let html: string
    if (ext === '.docx') html = (await mammoth.convertToHtml({ path: filePath })).value
    else if (ext === '.md') html = marked.parse(readFileSync(filePath, 'utf8')) as string
    else html = plainTextToEditorHtml(readFileSync(filePath, 'utf8'))
    const plain = stripHtml(html)
    const parts = splitPlainText(plain, splitMode, customRegex)
    // map to ImportChapterPreview with wordCount
  }

  async execute(params: ImportExecuteParams, onProgress?: (c: number, t: number) => void): Promise<string> {
    const preview = await this.preview(...)
    const meta = this.registry.create({ name: params.bookName, category: params.category, createSampleChapter: false })
    try {
      const db = this.registry.getDb(meta.id)
      const volId = new VolumeRepository(db).list()[0]?.id ?? new VolumeRepository(db).create('第一卷', 0).id
      for (let i = 0; i < preview.chapters.length; i++) {
        const ch = preview.chapters[i]
        new ChapterRepository(db).create(volId, ch.title, i, ch.contentHtml)
        onProgress?.(i + 1, preview.chapters.length)
      }
      return meta.id
    } catch (e) {
      this.registry.delete(meta.id)
      throw e
    }
  }
}
  • Step 3: import.handler

  • IMPORT_PICK_FILE / IMPORT_PREVIEW / IMPORT_EXECUTE

  • progressBrowserWindow.getAllWindows()[0]?.webContents.send(IPC.IMPORT_PROGRESS, { current, total })

  • Step 4: IT-IMPORT-01

  • Step 5: 运行

npm run test -- tests/main/import.service.test.ts

Task 11: ImportBookModal + HomePage

Files:

  • Create: src/renderer/components/import/ImportBookModal.tsx

  • Modify: src/renderer/components/home/HomePage.tsx

  • Modify: src/renderer/App.tsx(挂载 ImportBookModal

  • Step 1: ImportBookModal

  • data-testid="import-book-modal"

  • 浏览 → import.pickFile

  • 分章规则 select + 条件 regex 输入

  • 预览按钮 → import.preview 显示章数与前 5 标题

  • fileSizeBytes > 20 * 1024 * 1024 → confirm 对话框

  • 执行 → 监听 onImportProgressimport-progress-bar

  • 成功 → openBook(bookId) + 切 editor

  • Step 2: HomePage 替换 comingSoon

onClick={() => setImportOpen(true)}
  • Step 3: build
npm run build

Task 12: i18n + CSS + E2E + v1.1.0

Files:

  • Modify: public/locales/zh-CN/translation.json

  • Modify: public/locales/en/translation.json

  • Modify: src/renderer/styles/layout.css

  • Create: e2e/chapter-template.spec.ts

  • Create: e2e/import-book.spec.ts

  • Create: e2e/export-submission.spec.ts

  • Create: e2e/inspiration-capture.spec.ts

  • Modify: package.json

  • Modify: README.md

  • Modify: src/renderer/components/settings/SettingsPage.tsx(关于页版本)

  • Step 1: i18n 键spec §8 全部)

  • Step 2: CSS

.template-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
.import-preview-list { font-size: 12px; max-height: 120px; overflow: auto; }
.export-preview { font-family: var(--font-mono); font-size: 11px; white-space: pre-wrap; max-height: 200px; overflow: auto; }
.inspiration-modal--capture { z-index: 10000; }
  • Step 3: E2E
// E2E-TEMPL-01: 模板新建章 → ProseMirror 含模板正文
// E2E-IMPORT-01: 导入 fixture txt → 多章节可见
// E2E-EXPORT-01: 导出复制 → 剪贴板含 # 第
// E2E-INSP-01: 主页 Ctrl+Shift+I → toast,仍在 home
  • Step 4: 全量验证
npm run build
npm run test
npm run test:e2e -- e2e/chapter-template.spec.ts e2e/import-book.spec.ts e2e/export-submission.spec.ts e2e/inspiration-capture.spec.ts
  • Step 5: 版本 1.1.0

package.json / README.md / Settings 关于页 → v1.1.0

  • Step 6: 勾选本 plan 全部 checkbox

Spec Coverage Checklist

Spec 要求 Task
chapterTemplates + 内置模板 Task 1, 4
模板变量 + 快速新建 Task 2, 3
submissionPresets + 格式化 Task 1, 5, 6
ExportModal + 剪贴板/txt Task 7
全局灵感 + 不跳转 Task 8
txt/md/docx 导入 + 分章 Task 9, 10, 11
>20MB 确认 + 进度条 Task 11
i18n / E2E / v1.1.0 Task 12

Execution Notes

  • plainTextToEditorHtmlchapter-template.ts 可共享;主进程导入侧可 import 同逻辑或 duplicate 最小 HTML 包装
  • 导入 .docx 用 mammoth HTML 再 stripHtml 分章(章标题在纯文本层匹配)
  • export.formatChapter 与模板 {chapterNumber} 均用卷内 sortOrder 计算序号
  • E2E 导入使用 tests/fixtures/import-three-chapters.txt fixture 文件
  • 用户未要求时不自动 git commit;每 Task 完成后勾选 plan checkbox