Compare commits

...

16 Commits

Author SHA1 Message Date
bing fe421ffc55 feat(w5): ship v2.0.0 platform sync, undo persistence, and updates
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 18:24:51 +08:00
bing cb6b4c3731 feat(w4): ship v1.5.0 timeline, character arc, and compliance
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 17:40:42 +08:00
bing c5c0b7329b feat(w3): ship v1.4.0 export matrix, cockpit tasks, and focus ambient
Add ExportMatrixService for txt/md/docx/pdf, extend ExportModal, cockpit outline tasks, focus ambient sound, and E2E-PDF-01 with 134 passing unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 16:56:53 +08:00
bing c10be87d18 feat(w3): add continuous read mode and Wave 3 plan
Introduce ReadModeOverlay with device width and font theme persistence, sidebar and volume menu entry, plus E2E-READ-01 through 03.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 16:38:54 +08:00
bing fe6e411d2c feat(w2): ship v1.3.0 project pack UI, preload, and E2E
Wire .novel/.novel-all export-import through modals, settings backup, and home bulk export with progress IPC.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 16:31:16 +08:00
bing a4412793f4 feat(w2): add PackService for .novel and .novel-all export/import
Introduce pack-bootstrap chunk, ZipArchive-based PackService with progress IPC,
book registry import helpers, and IT-PACK-01 / IT-ALL-02 integration tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 15:36:47 +08:00
bing 9f2b1ae3e0 docs: add Wave 2 project pack implementation plan for v1.3.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 15:25:19 +08:00
bing fe127ec3ed feat: ship v1.2.0 Wave 1 foundation with book settings, chapter management, and focus mode
Deliver schema v9, book/chapter IPC extensions, BookSettingsTab, chapter meta/tags,
AI session menu, focus mode and TTS, template context enrich, and Wave 1 E2E coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 15:22:58 +08:00
bing 64da95f7e1 docs: add Wave 1 foundation implementation plan for v1.2.0
Covers schema v9, book settings, volume/chapter management UI, template context, AI session menu, focus mode, and TTS per master completion spec.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:23:19 +08:00
bing cc97c66d6f docs: add master completion design spec for full design/readme alignment
Define five-wave Master Program (v1.2 through v2.0) covering foundation UI, project packs, export/read modes, creative depth features, and platform infrastructure per brainstorming option A.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:18:01 +08:00
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
bing ea4819847f docs: add v1.1 writer toolkit implementation plan
Break down templates, import, submission export, and inspiration capture into twelve tracked tasks for the daily writing workflow release.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 11:07:49 +08:00
bing 027d7c7559 docs: add v1.1 writer toolkit design spec for templates, import, and export
Capture chapter templates, txt/md/docx import, submission presets, and global inspiration capture for the daily writing workflow release.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 11:05:06 +08:00
bing 4adafa1936 feat: ship v1.0.0 with character graph and writing analytics
Add P7 Cytoscape relationship graph with setting CRUD, P9 cockpit analytics driven by writing_sessions, chapter POV selection, and full test coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 10:48:31 +08:00
bing 2c9b034a29 docs: add P7 character graph and P9 writing analytics implementation plan for v1.0.0
Break down writing_sessions telemetry, Cytoscape graph UI, and cockpit analytics into twelve implementation tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 10:07:51 +08:00
bing e64353f608 docs: add P7 character graph and P9 writing analytics design spec for v1.0.0
Define Cytoscape relationship graph MVP, writing_sessions telemetry, and cockpit analytics cards for the v1.0.0 release.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 10:04:56 +08:00
193 changed files with 16816 additions and 347 deletions
+48 -3
View File
@@ -1,10 +1,55 @@
# 笔临 (Bilin)
长篇创作智能协作平台 — Electron 桌面客户端 v0.9.0P5.3 番茄钟/成就 + P6.1 知识注入历史
长篇创作智能协作平台 — Electron 桌面客户端 v2.0.0(平台基建
## 功能概览(v0.9.0
## 功能概览(v2.0.0
- 写作日志与热力图、连更统计、状态栏日更进度(P5.2)
- 撤销堆栈持久化:每章最多 50 条,跨会话 Ctrl+Z
- 云同步:加密 `.novel-all` → WebDAV / 本地目录
- 崩溃日志:`electron-log` + 异常退出提示
- 自动更新:`electron-updater`E2E 模式禁用)
- 扩展 API 骨架、内置 Skill / MCP 占位
## 功能概览(v1.5.0
- 故事时间线:章节/手动事件聚合、角色出生年冲突检测
- 角色弧线:设定 Tab 聚合知识库节点
- 敏感词合规:本地词库 + 编辑器波浪下划线
- 引用详情抽屉、章节悬停预览、自定义 AI 快捷命令
## 功能概览(v1.4.0
- 导出矩阵:章/卷/全书 → txt、md、docx(含目录)、pdfA4
- 连续阅读模式:设备宽度、字体主题、虚拟 3 章翻页
- 驾驶舱今日大纲任务、专注模式环境音效
## 功能概览(v1.3.0
- `.novel` 单书项目包导出/导入(含数据库、元数据、附件)
- `.novel-all` 批量导出/导入全部书籍与全局设置
- 主页「导出所有书籍」、设置页备份区、导出模态项目包选项
- 大文件导出确认与进度推送
## 功能概览(v1.2.0
- 书籍设置:书名/分类/目标字数/简介/封面、驾驶舱偏好、删书
- 分卷右键管理、章节双击改名/删除、章节元数据与标签
- 模板变量自动填充(含大纲项与角色列表)
- AI 会话重命名/归档/导出/删除
- 专注模式快捷键、TTS 朗读、顶栏日更进度条
## 功能概览(v1.1.0
- 章节模板与快速新建章(§10
- 导入 txt / md / docx 并自动分章(§11.1
- 投稿预设与单章导出(复制剪贴板 / 存 txt)(§20.2)
- 全局灵感捕获增强:任意界面快捷键、未开书可选书、保存仅 toast(§20.6)
## 功能概览(v1.0.0
- 角色关系图谱:Cytoscape 可视化、设定面板关系 CRUD、三种布局(P7)
- 个人写作分析:今日字速、时段热力、POV/卷分布、近 7 日趋势(P9)
- 章节视角(POV)选择与驾驶舱内嵌分析区
- AI 章节知识自动抽取、合并审核、高置信度批量采纳(P5.2)
- 知识库 AI 上下文:半自动推荐 + 勾选注入四种写作模式(P6)
- 驾驶舱:全书/本卷字数、存稿缓冲、伏笔摘要、快速入口(P5)
+47
View File
@@ -0,0 +1,47 @@
# 笔临扩展开发指南
笔临 v2.0.0 提供 **扩展 API 骨架**,用于声明第三方扩展点。当前版本**不加载或执行**第三方代码,仅校验 `manifest` 并展示占位列表。
## BilinExtension 清单
在扩展包的 `bilin-extension.json` 中声明:
```json
{
"name": "my-extension",
"version": "1.0.0",
"description": "示例扩展",
"main": "index.js",
"permissions": ["filesystem.read"],
"contributes": {
"importers": [{ "id": "myfmt", "extensions": [".mybook"], "label": "My Format" }],
"exporters": [{ "id": "myexport", "extensions": [".mybook"], "label": "Export My Format" }]
}
}
```
## 扩展点
| 扩展点 | 说明 |
|--------|------|
| `sidePanels` | 侧栏自定义面板 |
| `importers` | 自定义导入格式 |
| `exporters` | 自定义导出格式 |
| `aiBackends` | 可选 AI 后端 |
| `skills` | AI Skill |
| `editorExtensions` | TipTap 节点/标记 |
| `menuItems` | 工具栏/右键菜单 |
## 安全
- 扩展默认沙箱运行,需声明 `permissions`
- 敏感权限需用户在设置中显式授权(后续版本)
- 扩展仅能通过笔临提供的 API 与核心交互
## 开发者模式
设置 → 扩展 → 开启「开发者模式」后,可从本地文件夹读取 manifest 做校验(不执行 `main`)。
## 相关类型
`src/shared/extension-api.ts``src/main/services/extension-registry.ts`
@@ -0,0 +1,741 @@
# 笔临 P7 角色关系图谱 + P9 个人写作分析 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.0.0:§7 角色关系图谱(Cytoscape + 设定关系 CRUD+ §9 个人写作分析(`writing_sessions` 驱动速度/时段热力 + POV/卷分布),驾驶舱内嵌展示。
**Architecture:** 全局 `writing_logs.sqlite` 扩展 `writing_sessions` 表;`ChapterWritingTracker.recordDelta` 同步写 session`CharacterGraphService``settings.properties.relationships` 构建图 DTO`WritingAnalyticsService` 聚合 sessions/logs/pomodoro/章节;渲染进程 Cytoscape 模态 + 驾驶舱分析组件并行 IPC 请求。
**Tech Stack:** Electron 36、electron-vite、React 18、TypeScript、node:sqlite、Cytoscape.js、cytoscape-cose-bilkent、Zustand、i18next、Vitest、Playwright
**Spec:** `docs/superpowers/specs/2026-07-08-bilin-p7-graph-p9-analytics-design.md`
---
## File Map
| 文件 | 职责 |
|------|------|
| `src/shared/types.ts` | `CharacterRelationship``CharacterGraph*``WritingAnalyticsSummary` |
| `src/shared/ipc-channels.ts` | graph.* / analytics.summary |
| `src/main/db/repositories/writing-log.repo.ts` | migrate 扩展 writing_sessions |
| `src/main/db/repositories/writing-session.repo.ts` | session CRUD |
| `src/main/db/repositories/chapter.repo.ts` | povCharacterId mapRow/update |
| `src/main/services/writing-session.service.ts` | 5min idle 状态机 |
| `src/main/services/writing-analytics.service.ts` | getSummary 聚合 |
| `src/main/services/character-graph.service.ts` | 图谱 DTO + 关系 CRUD |
| `src/main/services/chapter-writing-tracker.service.ts` | recordDelta → session |
| `src/main/ipc/handlers/graph.handler.ts` | 图谱 IPC |
| `src/main/ipc/handlers/analytics.handler.ts` | 分析 IPC |
| `src/main/ipc/register.ts` | 注入新服务 |
| `src/preload/index.ts` + `electron-api.d.ts` | graph / analytics API |
| `src/renderer/components/graph/CharacterGraphModal.tsx` | Cytoscape UI |
| `src/renderer/stores/useGraphStore.ts` | 图谱模态状态 |
| `src/renderer/components/setting/SettingRelationshipsEditor.tsx` | 关系 CRUD |
| `src/renderer/components/cockpit/CockpitAnalytics.tsx` | 分析卡片 |
| `src/renderer/components/cockpit/HourlyHeatmap.tsx` | 24 槽热力 |
| `src/renderer/components/cockpit/CockpitModal.tsx` | 嵌入分析区 + 打开图谱 |
| `src/renderer/components/layout/EditorLayout.tsx` | chapter-pov-select |
| `src/renderer/components/setting/SettingList.tsx` | setting-open-graph |
| `src/renderer/styles/layout.css` | graph / analytics 样式 |
| `public/locales/*/translation.json` | graph / analytics / relationship / chapter.pov |
| `tests/main/writing-session.test.ts` | UT-SESSION-* |
| `tests/main/character-graph.test.ts` | UT-GRAPH-* / IT-GRAPH-01 |
| `tests/main/writing-analytics.test.ts` | UT-ANALYTICS-* / IT-ANALYTICS-01 |
| `e2e/character-graph.spec.ts` | E2E-GRAPH-* |
| `e2e/writing-analytics.spec.ts` | E2E-ANALYTICS-* |
---
## Task 1: 类型 + IPC 通道
**Files:**
- Modify: `src/shared/types.ts`
- Modify: `src/shared/ipc-channels.ts`
- [x] **Step 1: 扩展 types**
```typescript
export interface CharacterRelationship {
id: string
targetId: string
label: string
intimacy: 1 | 2 | 3 | 4 | 5
}
export interface CharacterGraphNode {
id: string
label: string
}
export interface CharacterGraphEdge {
id: string
source: string
target: string
label: string
intimacy: 1 | 2 | 3 | 4 | 5
}
export interface CharacterGraphData {
nodes: CharacterGraphNode[]
edges: CharacterGraphEdge[]
truncated: boolean
nodeCount: number
}
export interface WritingAnalyticsSummary {
todayWords: number
todaySpeedWpm: number | null
weekTrend: { date: string; words: number }[]
hourlyHeatmap: number[]
pomodoroToday: number
pomodoroAvgWords: number | null
povDistribution: { characterId: string; name: string; words: number }[]
volumeDistribution: { volumeId: string; title: string; words: number }[]
}
```
`SettingEntry.properties` 使用文档约定 `relationships?: CharacterRelationship[]`JSDoc 或 inline 注释即可)。
- [x] **Step 2: IPC 通道**
```typescript
GRAPH_GET_DATA: 'graph:getData',
GRAPH_UPSERT_RELATIONSHIP: 'graph:upsertRelationship',
GRAPH_DELETE_RELATIONSHIP: 'graph:deleteRelationship',
ANALYTICS_SUMMARY: 'analytics:summary',
```
- [x] **Step 3: build 验证**
```bash
npm run build
```
---
## Task 2: writing_sessions 表 + Repository
**Files:**
- Modify: `src/main/db/repositories/writing-log.repo.ts`
- Create: `src/main/db/repositories/writing-session.repo.ts`
- Create: `tests/main/writing-session.test.ts`repo 部分)
- [x] **Step 1: writing-log.repo migrate 扩展**
`migrate()``CREATE TABLE` 块追加:
```sql
CREATE TABLE IF NOT EXISTS writing_sessions (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL,
chapter_id TEXT,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
word_delta INTEGER NOT NULL DEFAULT 0,
date TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_book_date ON writing_sessions (book_id, date);
CREATE INDEX IF NOT EXISTS idx_sessions_book_started ON writing_sessions (book_id, started_at);
```
- [x] **Step 2: WritingSessionRepository**
```typescript
export class WritingSessionRepository {
constructor(private db: DatabaseSync) {}
insert(row: { id: string; bookId: string; chapterId?: string; startedAt: string; endedAt: string; wordDelta: number; date: string }): void
updateActivity(id: string, endedAt: string, wordDelta: number): void
listForBookDate(bookId: string, date: string): WritingSessionRow[]
listSince(bookId: string, sinceIso: string): WritingSessionRow[]
getById(id: string): WritingSessionRow | null
}
```
- [x] **Step 3: UT repo smoke**
```typescript
it('UT-SESSION-03: insert and updateActivity accumulates word_delta', () => {
const repo = new WritingSessionRepository(db)
const id = randomUUID()
repo.insert({ id, bookId: 'b1', chapterId: 'c1', startedAt: '2026-07-08T10:00:00', endedAt: '2026-07-08T10:05:00', wordDelta: 50, date: '2026-07-08' })
repo.updateActivity(id, '2026-07-08T10:10:00', 30)
expect(repo.getById(id)!.wordDelta).toBe(80)
})
```
- [x] **Step 4: 运行**
```bash
npm run test -- tests/main/writing-session.test.ts
```
---
## Task 3: WritingSessionService 状态机
**Files:**
- Create: `src/main/services/writing-session.service.ts`
- Modify: `tests/main/writing-session.test.ts`
- [x] **Step 1: 实现**
```typescript
const IDLE_MS = 5 * 60 * 1000
export class WritingSessionService {
private active = new Map<string, { sessionId: string; lastEndedAt: number }>()
constructor(private repo: WritingSessionRepository) {}
recordActivity(bookId: string, chapterId: string | undefined, delta: number): void {
if (delta === 0) return
const now = new Date()
const nowIso = now.toISOString()
const cached = this.active.get(bookId)
const idle = !cached || now.getTime() - cached.lastEndedAt > IDLE_MS
if (idle) {
const id = randomUUID()
this.repo.insert({
id, bookId, chapterId,
startedAt: nowIso, endedAt: nowIso,
wordDelta: delta,
date: localDateString()
})
this.active.set(bookId, { sessionId: id, lastEndedAt: now.getTime() })
} else {
this.repo.updateActivity(cached!.sessionId, nowIso, delta)
this.active.set(bookId, { sessionId: cached!.sessionId, lastEndedAt: now.getTime() })
}
}
endActive(bookId: string): void {
this.active.delete(bookId)
}
}
```
- [x] **Step 2: UT-SESSION-01 / UT-SESSION-02**
使用 `vi.useFakeTimers()`
```typescript
it('UT-SESSION-01: within 5min merges into one session', () => { /* advance 2min, recordActivity twice, expect 1 row */ })
it('UT-SESSION-02: after 6min creates two sessions', () => { /* advance 6min between calls */ })
```
- [x] **Step 3: 运行**
```bash
npm run test -- tests/main/writing-session.test.ts
```
---
## Task 4: ChapterWritingTracker + chapter POV 字段
**Files:**
- Modify: `src/main/services/chapter-writing-tracker.service.ts`
- Modify: `src/main/db/repositories/chapter.repo.ts`
- Modify: `src/main/ipc/handlers/chapter.handler.ts`UpdateChapterParams 含 povCharacterId
- Modify: `src/shared/types.ts`UpdateChapterParams
- Modify: `src/main/ipc/register.ts`trackerFor 注入 sessions
- [x] **Step 1: chapter.repo mapRow + update**
```typescript
// mapRow 增加
povCharacterId: (row.pov_character_id as string) || null,
// update patch 增加 povCharacterId?: string | null
// UPDATE SQL 增加 pov_character_id = ?
```
- [x] **Step 2: trackerFor 签名**
```typescript
export function trackerFor(
registry: BookRegistryService,
bookId: string,
writingLogs: WritingLogService,
sessions: WritingSessionService
): ChapterWritingTracker
recordDelta(...) {
if (delta !== 0) {
this.writingLogs.addToday(this.bookId, delta)
this.sessions.recordActivity(this.bookId, chapterId, delta)
}
// ...
}
supplementOnFinish(...) {
if (gap > 0) {
this.writingLogs.addToday(this.bookId, gap)
this.sessions.recordActivity(this.bookId, chapterId, gap)
}
}
```
- [x] **Step 3: register.ts**
```typescript
const writingSessionRepo = new WritingSessionRepository(writingLogRepo.getDb())
const writingSessions = new WritingSessionService(writingSessionRepo)
// registerChapterHandlers(books, writingLogs, writingSessions)
```
- [x] **Step 4: build**
```bash
npm run build
```
---
## Task 5: CharacterGraphService
**Files:**
- Create: `src/main/services/character-graph.service.ts`
- Create: `tests/main/character-graph.test.ts`
- [x] **Step 1: getData 边去重**
```typescript
export class CharacterGraphService {
constructor(private registry: BookRegistryService) {}
getData(bookId: string, filter: 'all' | 'connected' = 'all'): CharacterGraphData {
const settings = new SettingRepository(this.registry.getDb(bookId)).list()
const characters = settings.filter((s) => s.type === 'character')
const truncated = characters.length > 100
// 收集 raw edges from properties.relationships
// dedupeEdges: key = sorted([source,target]).join('|'), keep max intimacy
// filter connected: nodes with degree > 0
// if truncated: slice nodes to 100 by sortOrder/name
}
}
```
- [x] **Step 2: upsertRelationship / deleteRelationship**
```typescript
upsertRelationship(bookId, sourceId, input: Partial<CharacterRelationship> & { targetId: string; label: string; intimacy: number }): CharacterRelationship {
// validate source/target are character; targetId !== sourceId
// merge into properties.relationships by id or push new uuid
// settingRepo.update(sourceId, { properties: { ...existing, relationships } })
}
deleteRelationship(bookId, sourceId, relationshipId): void
```
- [x] **Step 3: UT-GRAPH-01 / UT-GRAPH-02 / UT-GRAPH-03**
内存 DB + migrate + 创建 3 character settings + 2 relationships。
- [x] **Step 4: 运行**
```bash
npm run test -- tests/main/character-graph.test.ts
```
---
## Task 6: WritingAnalyticsService
**Files:**
- Create: `src/main/services/writing-analytics.service.ts`
- Create: `tests/main/writing-analytics.test.ts`
- [x] **Step 1: getSummary 实现**
```typescript
export class WritingAnalyticsService {
constructor(
private writingLogs: WritingLogService,
private sessionRepo: WritingSessionRepository,
private pomodoroDaily: PomodoroDailyRepository,
private registry: BookRegistryService
) {}
getSummary(bookId: string): WritingAnalyticsSummary {
const today = localDateString()
const todaySessions = this.sessionRepo.listForBookDate(bookId, today)
const todayWords = this.writingLogs.getStats(bookId).todayWords
const todaySpeedWpm = computeWpm(todaySessions) // null if zero duration
const weekTrend = buildWeekTrend(this.writingLogs, bookId)
const hourlyHeatmap = buildHourlyHeatmap(this.sessionRepo.listSince(bookId, thirtyDaysAgoIso))
// povDistribution from ChapterRepository + SettingRepository names
// volumeDistribution from VolumeRepository titles
// pov 未指定 → characterId: '__none__'
}
}
```
辅助函数:
```typescript
function computeWpm(sessions: { wordDelta: number; startedAt: string; endedAt: string }[]): number | null {
const totalWords = sessions.reduce((s, x) => s + x.wordDelta, 0)
const totalMin = sessions.reduce((s, x) => s + minutesBetween(x.startedAt, x.endedAt), 0)
if (totalMin <= 0) return null
return Math.round(totalWords / totalMin)
}
```
- [x] **Step 2: UT-ANALYTICS-01 / UT-ANALYTICS-02**
- [x] **Step 3: IT-ANALYTICS-01**
```typescript
it('IT-ANALYTICS-01: recordDelta feeds session then getSummary', () => {
const tracker = trackerFor(registry, bookId, writingLogs, sessions)
tracker.recordDelta(chapterId, 100)
const summary = analytics.getSummary(bookId)
expect(summary.todaySpeedWpm).toBeGreaterThan(0)
})
```
- [x] **Step 4: 运行**
```bash
npm run test -- tests/main/writing-analytics.test.ts
```
---
## Task 7: IPC + register + preload
**Files:**
- Create: `src/main/ipc/handlers/graph.handler.ts`
- Create: `src/main/ipc/handlers/analytics.handler.ts`
- Modify: `src/main/ipc/register.ts`
- Modify: `src/main/ipc/handlers/chapter.handler.ts`
- Modify: `src/preload/index.ts`
- Modify: `src/shared/electron-api.d.ts`
- [x] **Step 1: graph.handler**
```typescript
export function registerGraphHandlers(registry: BookRegistryService): void {
const svc = () => new CharacterGraphService(registry)
ipcMain.handle(IPC.GRAPH_GET_DATA, (_e, { bookId, filter }) => wrap(() => svc().getData(bookId, filter)))
ipcMain.handle(IPC.GRAPH_UPSERT_RELATIONSHIP, (_e, { bookId, sourceId, relationship }) =>
wrap(() => svc().upsertRelationship(bookId, sourceId, relationship)))
ipcMain.handle(IPC.GRAPH_DELETE_RELATIONSHIP, (_e, { bookId, sourceId, relationshipId }) =>
wrap(() => { svc().deleteRelationship(bookId, sourceId, relationshipId) }))
}
```
- [x] **Step 2: analytics.handler**
```typescript
export function registerAnalyticsHandlers(
registry: BookRegistryService,
writingLogs: WritingLogService,
sessionRepo: WritingSessionRepository,
pomodoroDaily: PomodoroDailyRepository
): void {
const svc = new WritingAnalyticsService(writingLogs, sessionRepo, pomodoroDaily, registry)
ipcMain.handle(IPC.ANALYTICS_SUMMARY, (_e, { bookId }) => wrap(() => svc.getSummary(bookId)))
}
```
- [x] **Step 3: register.ts 串联**
```typescript
const writingSessionRepo = new WritingSessionRepository(writingLogRepo.getDb())
const writingSessions = new WritingSessionService(writingSessionRepo)
registerGraphHandlers(books)
registerAnalyticsHandlers(books, writingLogs, writingSessionRepo, pomodoroDailyRepo)
registerChapterHandlers(books, writingLogs, writingSessions)
```
- [x] **Step 4: preload**
```typescript
graph: {
getData: (bookId, filter?) => ipcRenderer.invoke(IPC.GRAPH_GET_DATA, { bookId, filter }),
upsertRelationship: (bookId, sourceId, relationship) => ...,
deleteRelationship: (bookId, sourceId, relationshipId) => ...
},
analytics: {
getSummary: (bookId) => ipcRenderer.invoke(IPC.ANALYTICS_SUMMARY, { bookId })
}
```
- [x] **Step 5: build**
```bash
npm run build
```
---
## Task 8: Cytoscape 依赖 + CharacterGraphModal
**Files:**
- Modify: `package.json`
- Create: `src/renderer/stores/useGraphStore.ts`
- Create: `src/renderer/components/graph/CharacterGraphModal.tsx`
- Create: `src/renderer/lib/graph-cytoscape.ts`init / layout / intimacy 颜色)
- Modify: `src/renderer/components/layout/EditorLayout.tsx`(挂载模态)
- Modify: `src/renderer/styles/layout.css`
- [x] **Step 1: 安装依赖**
```bash
npm install cytoscape cytoscape-cose-bilkent --legacy-peer-deps
npm install -D @types/cytoscape --legacy-peer-deps
```
- [x] **Step 2: useGraphStore**
```typescript
interface GraphStore {
open: boolean
data: CharacterGraphData | null
loading: boolean
openModal: () => void
close: () => void
load: (bookId: string, filter?: 'all' | 'connected') => Promise<void>
}
```
- [x] **Step 3: CharacterGraphModal 骨架**
```tsx
export function CharacterGraphModal(): React.JSX.Element | null {
const { open, data, loading, close, load } = useGraphStore()
const bookId = useBookStore((s) => s.currentBookId)
const cyRef = useRef<cytoscape.Core | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open || !bookId) return
void load(bookId)
}, [open, bookId, load])
useEffect(() => {
if (!open || !data || !containerRef.current) return
cyRef.current = initCytoscape(containerRef.current, data)
return () => { cyRef.current?.destroy(); cyRef.current = null }
}, [open, data])
// 布局按钮 graph-layout-cose/grid/circle
// tap node → sidebar; tap edge → sidebar edit intimacy
// truncated toast
}
```
`initCytoscape` 注册 `cytoscape.use(coseBilkent)`,边颜色按 intimacy 映射 spec §6.1。
- [x] **Step 4: EditorLayout 挂载 `<CharacterGraphModal />`**
- [x] **Step 5: build**
```bash
npm run build
```
---
## Task 9: SettingRelationshipsEditor + 入口
**Files:**
- Create: `src/renderer/components/setting/SettingRelationshipsEditor.tsx`
- Modify: `src/renderer/components/editor/TipTapEditor.tsx`character setting 下方嵌入)
- Modify: `src/renderer/components/setting/SettingList.tsx`setting-open-graph 按钮)
- [x] **Step 1: SettingRelationshipsEditor**
`target.kind === 'setting'` 且 entry.type === 'character'
```tsx
<div data-testid="setting-relationships">
{relationships.map((r) => (
<div key={r.id}>{targetName(r.targetId)} · {r.label} · {r.intimacy}
<button onClick={() => void deleteRel(r.id)}></button>
</div>
))}
<button data-testid="setting-relationship-add" onClick={openAddForm}>+</button>
</div>
```
保存调用 `window.electronAPI.graph.upsertRelationship`
- [x] **Step 2: SettingList 工具栏**
```tsx
<button data-testid="setting-open-graph" onClick={() => useGraphStore.getState().openModal()}>
{t('graph.title')}
</button>
```
- [x] **Step 3: build**
```bash
npm run build
```
---
## Task 10: CockpitAnalytics + POV 选择
**Files:**
- Create: `src/renderer/components/cockpit/CockpitAnalytics.tsx`
- Create: `src/renderer/components/cockpit/HourlyHeatmap.tsx`
- Modify: `src/renderer/components/cockpit/CockpitModal.tsx`
- Modify: `src/renderer/components/layout/EditorLayout.tsx`chapter-pov-select
- Modify: `src/renderer/stores/useCockpitStore.ts`(可选:loadAnalytics
- [x] **Step 1: CockpitModal 并行加载**
```typescript
useEffect(() => {
if (!open || !bookId) return
void Promise.all([
loadSummary(bookId, activeVolumeId ?? undefined),
ipcCall(() => window.electronAPI.analytics.getSummary(bookId)).then(setAnalytics)
])
}, [open, bookId, activeVolumeId])
```
- [x] **Step 2: CockpitAnalytics 组件**
```tsx
<div className="cockpit-analytics" data-testid="cockpit-analytics">
<div data-testid="cockpit-speed-today">{speedLabel}</div>
<HourlyHeatmap data={analytics.hourlyHeatmap} testId="cockpit-hourly-heatmap" />
<div data-testid="cockpit-pov-chart">{/* top 5 bars */}</div>
<div data-testid="cockpit-volume-chart">{/* volume bars */}</div>
<div data-testid="cockpit-week-trend">{/* 7 mini bars */}</div>
</div>
<button data-testid="cockpit-open-graph" onClick={() => useGraphStore.getState().openModal()}>
{t('cockpit.openGraph')}
</button>
```
- [x] **Step 3: chapter-pov-select**
`#editor-statusbar``isChapterTarget`
```tsx
<select
data-testid="chapter-pov-select"
value={chapter.povCharacterId ?? ''}
onChange={(e) => void updatePov(e.target.value || null)}
>
<option value="">{t('analytics.povUnassigned')}</option>
{characters.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
```
`updatePov``chapter.update(bookId, chapterId, { povCharacterId })`.
- [x] **Step 4: build**
```bash
npm run build
```
---
## Task 11: i18n + 样式
**Files:**
- Modify: `public/locales/zh-CN/translation.json`
- Modify: `public/locales/en/translation.json`
- Modify: `src/renderer/styles/layout.css`
- [x] **Step 1: 添加 spec §7 全部 i18n 键(graph / relationship / analytics / chapter.pov / cockpit.openGraph**
- [x] **Step 2: CSS**
```css
.character-graph-modal .modal-content { max-width: 95vw; width: 1100px; }
#character-graph-cy { min-height: 480px; flex: 1; }
.graph-sidebar { width: 260px; ... }
.cockpit-analytics { margin-top: 16px; display: grid; gap: 12px; }
.hourly-heatmap { display: flex; gap: 2px; height: 32px; }
.hourly-heatmap-cell { flex: 1; border-radius: 2px; background: var(--bg-tertiary); }
.pov-bar, .volume-bar { height: 8px; background: var(--accent); border-radius: 4px; }
```
- [x] **Step 3: build**
```bash
npm run build
```
---
## Task 12: E2E + v1.0.0 交付
**Files:**
- Create: `e2e/character-graph.spec.ts`
- Create: `e2e/writing-analytics.spec.ts`
- Modify: `package.json`
- Modify: `README.md`
- Modify: `src/renderer/components/settings/SettingsPage.tsx`
- [x] **Step 1: E2E-GRAPH-01~04**
```typescript
// 创建书 → 新建 character 设定 A/B → SettingRelationshipsEditor 添加关系
// → setting-open-graph → expect character-graph-cy visible
// → tap node → sidebar 含角色名
// → graph-layout-circle click
```
- [x] **Step 2: E2E-ANALYTICS-01~02**
```typescript
// E2E-ANALYTICS-01: 写作保存 → topbar-cockpit → cockpit-speed-today 非空
// E2E-ANALYTICS-02: chapter-pov-select 选角色 → 刷新驾驶舱 → cockpit-pov-chart 含角色名
```
- [x] **Step 3: 全量测试**
```bash
npm run build
npm run test
npm run test:e2e -- e2e/character-graph.spec.ts e2e/writing-analytics.spec.ts
```
- [x] **Step 4: 版本 1.0.0**
`package.json` / `README.md` / Settings 关于页 → `v1.0.0`
- [x] **Step 5: 勾选本 plan 全部 checkbox**
---
## Spec Coverage Checklist
| Spec 要求 | Task |
|-----------|------|
| writing_sessions + 5min 合并 | Task 2, 3, 4 |
| recordDelta 写 session | Task 4 |
| CharacterGraphService + 边去重 | Task 5 |
| WritingAnalyticsService | Task 6 |
| graph / analytics IPC | Task 7 |
| Cytoscape 模态 + 三布局 | Task 8 |
| SettingRelationshipsEditor | Task 9 |
| 驾驶舱分析区 | Task 10 |
| chapter POV | Task 4, 10 |
| i18n | Task 11 |
| UT/IT/E2E | Task 26, 12 |
| v1.0.0 DoD | Task 12 |
---
## Execution Notes
- `CharacterGraphService``SettingRelationshipsEditor` 共用 `graph.upsertRelationship` IPC,避免 renderer 直接 patch `properties` JSON
- Cytoscape 仅在 modal open 时 mountclose 必须 `destroy()` 防内存泄漏
- 书籍切换时调用 `writingSessions.endActive(oldBookId)`(在 `useBookStore.openBook` 或 main 层 hook
- POV 分布 `__none__` 桶在 i18n 显示为 `analytics.povUnassigned`
- E2E 图谱/分析 **禁止 Mock AI**
@@ -0,0 +1,663 @@
# 笔临 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`;主进程 `ImportService`mammoth/marked + `chapter-splitter`)与 `ExportService``submission-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` | `ChapterTemplate``SubmissionPreset``Import*``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`
- [x] **Step 1: 扩展 types**
```typescript
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
```
- [x] **Step 2: builtin-templates.ts**
```typescript
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')]
}
```
- [x] **Step 3: global-settings defaults**
`defaults()` 追加 `chapterTemplates: []`, `submissionPresets: []`, `defaultSubmissionPresetId: 'builtin-webnovel'`
- [x] **Step 4: useSettingsStore 初始字段对齐**
- [x] **Step 5: build**
```bash
npm run build
```
---
## Task 2: chapter-template 引擎 + 单元测试
**Files:**
- Create: `src/renderer/lib/chapter-template.ts`
- Create: `tests/main/chapter-template.test.ts`
- [x] **Step 1: 实现 replaceChapterTemplate**
```typescript
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('')
}
```
- [x] **Step 2: UT-TEMPL-01~03**
```typescript
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章 觉醒')
})
```
- [x] **Step 3: 运行**
```bash
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`
- [x] **Step 1: TemplateQuickCreateModal**
- `data-testid="chapter-quick-new"` 打开模态
-`useSettingsStore` + `mergeChapterTemplates` 展示模板卡片
- 创建流程:`chapter.create``replaceChapterTemplate``plainTextToEditorHtml``chapter.update({ content })`
- 若模板 `defaultStatus` 设置则 `chapter.update({ status })`
- `switchTarget` 到新章
- [x] **Step 2: EditorLayout**
在章节面板底部保留原「+ 新章」按钮,新增「模板新建」按钮 `data-testid="chapter-quick-new"`
- [x] **Step 3: build**
```bash
npm run build
```
---
## Task 4: 设置页章节模板 Tab
**Files:**
- Create: `src/renderer/components/settings/TemplateSettingsTab.tsx`
- Modify: `src/renderer/components/settings/SettingsPage.tsx`
- [x] **Step 1: TemplateSettingsTab**
- `data-testid="settings-chapter-templates"`
- 内置模板只读展示
- 自定义:`setting-template-add` 新建;编辑 name/body;删除(uuid id
- 保存:`settings.update({ chapterTemplates: customOnly })`
- [x] **Step 2: SettingsPage 导航**
新增 section `'templates'`,侧栏项 `settings.templates`
- [x] **Step 3: build**
```bash
npm run build
```
---
## Task 5: submission-formatter + 单元测试
**Files:**
- Create: `src/main/lib/submission-formatter.ts`
- Create: `tests/main/submission-formatter.test.ts`
- [x] **Step 1: 实现**
```typescript
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)}`
}
```
- [x] **Step 2: UT-EXPORT-01 / UT-EXPORT-02**
- [x] **Step 3: 运行**
```bash
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`
- [x] **Step 1: ExportService**
```typescript
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
}
}
```
- [x] **Step 2: export.handler + register + preload**
```typescript
EXPORT_FORMAT_CHAPTER: 'export:formatChapter',
EXPORT_COPY_CLIPBOARD: 'export:copyClipboard',
EXPORT_SAVE_TXT: 'export:saveTxt',
```
- [x] **Step 3: build**
```bash
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`
- [x] **Step 1: ExportModal**
- `data-testid="export-modal"`
- `export-preset-select` 下拉(mergeSubmissionPresets
- 预览区:调用 `export.formatChapter` 显示前 500 字
- `export-copy-clipboard` / `export-save-txt`
- [x] **Step 2: SubmissionPresetTab**
- `data-testid="settings-submission-presets"`
- CRUD 自定义预设(不可删 builtin-webnovel
- [x] **Step 3: EditorLayout 工具栏 + App exportBook 快捷键**
```typescript
if (action === 'exportBook') {
if (view !== 'editor' || !chapterId) return
useExportStore.getState().openModal()
return
}
```
创建 `useExportStore`open/close,类似 useGraphStore)。
- [x] **Step 4: build**
```bash
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`
- [x] **Step 1: App.tsx 移除 view 限制**
```typescript
if (action === 'captureInspiration') {
useAppStore.getState().setInspirationModalOpen(true)
return
}
```
- [x] **Step 2: InspirationModal**
-`bookId` 时显示 `inspiration-book-select``useBookStore.books``lastOpenedAt` 排序)
- `handleSave` 删除 `switchTarget` / `setSidebarPanel`;改为 `showToast(t('inspiration.saved'))`
- 添加 `inspiration-modal--capture` class
- [x] **Step 3: E2E 预备 build**
```bash
npm run build
```
---
## Task 9: chapter-splitter + 单元测试
**Files:**
- Create: `src/main/lib/chapter-splitter.ts`
- Create: `tests/main/chapter-splitter.test.ts`
- [x] **Step 1: splitPlainText**
```typescript
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') 分行匹配
}
```
- [x] **Step 2: UT-IMPORT-01** — 样本文本 3 章标题
- [x] **Step 3: 运行**
```bash
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`
- [x] **Step 1: 安装依赖**
```bash
npm install mammoth marked --legacy-peer-deps
npm install -D @types/marked --legacy-peer-deps
```
- [x] **Step 2: ImportService**
```typescript
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
}
}
}
```
- [x] **Step 3: import.handler**
- `IMPORT_PICK_FILE` / `IMPORT_PREVIEW` / `IMPORT_EXECUTE`
- progress`BrowserWindow.getAllWindows()[0]?.webContents.send(IPC.IMPORT_PROGRESS, { current, total })`
- [x] **Step 4: IT-IMPORT-01**
- [x] **Step 5: 运行**
```bash
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
- [x] **Step 1: ImportBookModal**
- `data-testid="import-book-modal"`
- 浏览 → `import.pickFile`
- 分章规则 select + 条件 regex 输入
- 预览按钮 → `import.preview` 显示章数与前 5 标题
- `fileSizeBytes > 20 * 1024 * 1024` → confirm 对话框
- 执行 → 监听 `onImportProgress``import-progress-bar`
- 成功 → `openBook(bookId)` + 切 editor
- [x] **Step 2: HomePage 替换 comingSoon**
```typescript
onClick={() => setImportOpen(true)}
```
- [x] **Step 3: build**
```bash
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`(关于页版本)
- [x] **Step 1: i18n 键**spec §8 全部)
- [x] **Step 2: CSS**
```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; }
```
- [x] **Step 3: E2E**
```typescript
// E2E-TEMPL-01: 模板新建章 → ProseMirror 含模板正文
// E2E-IMPORT-01: 导入 fixture txt → 多章节可见
// E2E-EXPORT-01: 导出复制 → 剪贴板含 # 第
// E2E-INSP-01: 主页 Ctrl+Shift+I → toast,仍在 home
```
- [x] **Step 4: 全量验证**
```bash
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
```
- [x] **Step 5: 版本 1.1.0**
`package.json` / `README.md` / Settings 关于页 → `v1.1.0`
- [x] **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
- `plainTextToEditorHtml``chapter-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
@@ -0,0 +1,452 @@
# 笔临 Wave 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 (`- [ ]`) syntax for tracking.
**Goal:** 交付 v1.2.0:清除 Wave 1 范围内全部 `comingSoon` 占位,补全书籍/分卷/章节管理 UI,模板变量自动填充,AI 会话管理,专注模式与 TTS 快捷键。
**Architecture:** schema v9 扩展 `chapter_tags` / `published_at` / `book_preferences.always_show_cockpit`;扩展 `BookMeta``BOOK_UPDATE_META`;渲染进程新增 `BookSettingsTab``FocusMode` overlay、`ChapterMetaPanel`;复用已有 `volume.*` / `chapter.delete` IPC`chapter-template` 上下文从 outlines/settings 聚合。
**Tech Stack:** Electron 36、electron-vite、React 18、TypeScript、node:sqlite、Zustand、i18next、Vitest、Playwright
**Spec:** `docs/superpowers/specs/2026-07-08-bilin-master-completion-design.md` §5
**Master Program:** Wave 1/5 → v1.2.0
---
## File Map
| 文件 | 职责 |
|------|------|
| `src/main/db/schema-v9.sql` | chapter_tags 表 |
| `src/main/db/migrate.ts` | CURRENT_VERSION=9published_at/word_count_threshold |
| `src/main/db/repositories/chapter-tag.repo.ts` | 章节标签 CRUD |
| `src/main/db/repositories/chapter.repo.ts` | published_at、summary、story_time、threshold 读写 |
| `src/main/services/book-registry.ts` | updateMeta 扩展 name/category/targetWordCount/coverPath/synopsis |
| `src/main/services/cockpit.service.ts` | always_show_cockpit 逻辑 |
| `src/main/ipc/handlers/book.handler.ts` | BOOK_UPDATE_META 扩展 |
| `src/main/ipc/handlers/chapter.handler.ts` | CHAPTER_TAG_*、published_at on setPublishStatus |
| `src/main/ipc/handlers/ai.handler.ts` | AI_SESSION_EXPORT |
| `src/shared/types.ts` | BookMeta、UpdateBookMetaParams、ChapterTag |
| `src/shared/ipc-channels.ts` | 新 IPC 常量 |
| `src/preload/index.ts` + `electron-api.d.ts` | bookPrefs、chapterTags、sessionExport |
| `src/shared/chapter-template.ts` | `buildTemplateContext()` 含 outlineItem/characterList |
| `src/renderer/components/book/BookSettingsTab.tsx` | 右栏书籍设置 |
| `src/renderer/components/chapter/ChapterMetaPanel.tsx` | summary/story_time/tags |
| `src/renderer/components/volume/VolumeContextMenu.tsx` | 分卷右键 |
| `src/renderer/components/focus/FocusModeOverlay.tsx` | 专注模式 |
| `src/renderer/lib/tts-controller.ts` | TTS 朗读 |
| `src/renderer/components/layout/RightPanel.tsx` | 接入 BookSettingsTab |
| `src/renderer/components/layout/EditorLayout.tsx` | 章双击改名、删除、meta、阈值变色 |
| `src/renderer/components/layout/TopBar.tsx` | 日更进度条(细条) |
| `src/renderer/components/ai/AiSessionMenu.tsx` | 会话重命名/归档/导出/删除 |
| `src/renderer/components/ai/AiPanel.tsx` | 接入 AiSessionMenu |
| `src/renderer/components/wordfreq/WordFreqPanel.tsx` | 命名检查入口 |
| `src/renderer/App.tsx` | focusMode / toggleTTS 快捷键 |
| `src/renderer/stores/useBookPrefsStore.ts` | alwaysShowCockpit |
| `public/locales/*/translation.json` | bookSettings / chapterMeta / focus / tts |
| `tests/main/chapter-template-context.test.ts` | UT outlineItem/characterList |
| `tests/main/chapter-tag.repo.test.ts` | IT 标签 |
| `e2e/book-settings.spec.ts` | E2E 书籍设置+删书 |
| `e2e/chapter-management.spec.ts` | E2E 章改名+删除 |
| `e2e/focus-tts.spec.ts` | E2E 专注+TTS |
| `e2e/chapter-template.spec.ts` | 扩展 E2E-TEMPL-05 |
---
## Task 1: Schema v9 + 类型扩展
**Files:**
- Create: `src/main/db/schema-v9.sql`
- Modify: `src/main/db/migrate.ts`
- Modify: `src/shared/types.ts`
- Modify: `src/shared/ipc-channels.ts`
- [x] **Step 1: schema-v9.sql**
```sql
CREATE TABLE IF NOT EXISTS chapter_tags (
chapter_id TEXT NOT NULL,
tag TEXT NOT NULL,
color TEXT DEFAULT '',
PRIMARY KEY (chapter_id, tag),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
```
- [x] **Step 2: migrate.ts**`CURRENT_VERSION = 9`,新增 block
```typescript
if (current < 9) {
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN published_at TEXT DEFAULT NULL')
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN word_count_threshold INTEGER DEFAULT NULL')
db.exec(schemaV9)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(9, 'W1 foundation')
}
```
- [x] **Step 3: types.ts** — 扩展:
```typescript
export interface BookMeta {
// existing...
coverPath?: string | null
synopsis?: string
}
export interface UpdateBookMetaParams {
name?: string
category?: string
targetWordCount?: number | null
coverPath?: string | null
synopsis?: string
lastChapterId?: string | null
status?: BookStatus
}
export interface ChapterTag {
chapterId: string
tag: string
color: string
}
// Chapter 已有 summary?, storyTime? — 确保 chapter.update IPC 接受它们
```
- [x] **Step 4: ipc-channels.ts**
```typescript
CHAPTER_TAG_LIST: 'chapter:tagList',
CHAPTER_TAG_SET: 'chapter:tagSet',
CHAPTER_TAG_REMOVE: 'chapter:tagRemove',
BOOK_PREFS_GET: 'book:prefsGet',
BOOK_PREFS_SET: 'book:prefsSet',
AI_SESSION_EXPORT: 'ai:sessionExport',
```
- [x] **Step 5: 运行** `npm run build` — 应通过
---
## Task 2: Repository + BookRegistry 扩展
**Files:**
- Create: `src/main/db/repositories/chapter-tag.repo.ts`
- Modify: `src/main/db/repositories/chapter.repo.ts`
- Modify: `src/main/services/book-registry.ts`
- Modify: `src/main/services/cockpit.service.ts`
- [x] **Step 1: ChapterTagRepository**
```typescript
export class ChapterTagRepository {
constructor(private db: SqliteDb) {}
list(chapterId: string): ChapterTag[] { /* SELECT */ }
set(chapterId: string, tag: string, color = ''): void { /* INSERT OR REPLACE */ }
remove(chapterId: string, tag: string): void { /* DELETE */ }
listAll(): ChapterTag[] { /* for book open */ }
}
```
- [x] **Step 2: chapter.repo update**`update()``setPublishStatus()` 支持 `summary`, `storyTime`, `wordCountThreshold`, `publishedAt`
```typescript
setPublishStatus(id: string, publishStatus: PublishStatus): Chapter {
const patch: Parameters<typeof this.update>[1] = { publishStatus }
if (publishStatus === 'published') {
patch.publishedAt = new Date().toISOString()
}
return this.update(id, patch)
}
```
- [x] **Step 3: book-registry.updateMeta** — 签名改为 `Partial<Pick<BookMeta, 'name' | 'category' | 'targetWordCount' | 'coverPath' | 'synopsis' | 'lastOpenedAt' | 'lastChapterId' | 'status'>>`
- [x] **Step 4: cockpit.service**
```typescript
shouldShowOnOpen(): boolean {
const prefs = new BookPrefsRepository(this.db)
if (prefs.get('alwaysShowCockpit') === 'true') return true
return prefs.get('cockpitSeen') !== 'true'
}
```
- [x] **Step 5: UT** `tests/main/chapter-tag.repo.test.ts` — 创建章+标签+列表
Run: `npm run test -- --run tests/main/chapter-tag.repo.test.ts`
---
## Task 3: IPC handlers + preload
**Files:**
- Modify: `src/main/ipc/handlers/book.handler.ts`
- Modify: `src/main/ipc/handlers/chapter.handler.ts`
- Create: `src/main/ipc/handlers/book-prefs.handler.ts`
- Modify: `src/main/ipc/handlers/ai.handler.ts`
- Modify: `src/main/ipc/register.ts`
- Modify: `src/preload/index.ts`
- Modify: `src/shared/electron-api.d.ts`
- [x] **Step 1: book.handler**`BOOK_UPDATE_META` 接受 `UpdateBookMetaParams`
- [x] **Step 2: chapter.handler** — 扩展 `CHAPTER_UPDATE` 接受 `summary`, `storyTime`, `wordCountThreshold`;新增:
```typescript
ipcMain.handle(IPC.CHAPTER_TAG_LIST, (_e, { bookId, chapterId }) =>
wrap(() => new ChapterTagRepository(registry.getDb(bookId)).list(chapterId)))
// TAG_SET / TAG_REMOVE 同理
```
- [x] **Step 3: book-prefs.handler**
```typescript
ipcMain.handle(IPC.BOOK_PREFS_GET, (_e, { bookId, key }) =>
wrap(() => new BookPrefsRepository(registry.getDb(bookId)).get(key)))
ipcMain.handle(IPC.BOOK_PREFS_SET, (_e, { bookId, key, value }) =>
wrap(() => { new BookPrefsRepository(registry.getDb(bookId)).set(key, value) }))
```
- [x] **Step 4: ai.handler**`AI_SESSION_EXPORT`:读 messages,格式化 markdown`dialog.showSaveDialog`,写文件
- [x] **Step 5: preload**`book.updateMeta` 扩展;`bookPrefs.get/set``chapterTag.list/set/remove``ai.session.export(bookId, sessionId, format)`
- [x] **Step 6: build** `npm run build`
---
## Task 4: 模板上下文 enrich
**Files:**
- Modify: `src/shared/chapter-template.ts`
- Modify: `src/renderer/components/chapter/TemplateQuickCreateModal.tsx`
- Create: `tests/main/chapter-template-context.test.ts`
- [x] **Step 1: 新增 `buildTemplateContext()`**
```typescript
export function buildTemplateContext(input: {
chapterTitle: string
penName: string
volumes: Volume[]
chapters: Chapter[]
outlines: OutlineItem[]
settings: SettingEntry[]
activeVolumeId: string | null
selectedChapterId?: string | null
}): TemplateContext {
const volChapters = input.chapters
.filter((c) => c.volumeId === input.activeVolumeId)
.sort((a, b) => a.sortOrder - b.sortOrder)
const chapterNumber = volChapters.length + 1
const outlineItem =
input.outlines.find((o) => o.chapterId === input.selectedChapterId)?.title ?? ''
const characterList = input.settings
.filter((s) => s.type === 'character')
.slice(0, 8)
.map((s) => s.name)
.join('、')
return {
chapterNumber,
chapterTitle: input.chapterTitle,
penName: input.penName,
date: new Date().toLocaleDateString('zh-CN'),
volumeName: input.volumes.find((v) => v.id === input.activeVolumeId)?.name ?? '',
previousChapterTitle: volChapters.at(-1)?.title ?? '',
outlineItem,
characterList
}
}
```
- [x] **Step 2: TemplateQuickCreateModal** — 用 `buildTemplateContext` 替换内联 `buildContext`
- [x] **Step 3: UT**
```typescript
it('UT-TEMPL-05: characterList from settings', () => {
const ctx = buildTemplateContext({ /* 2 characters */ })
expect(ctx.characterList).toContain('林远')
})
```
Run: `npm run test -- --run tests/main/chapter-template-context.test.ts`
---
## Task 5: BookSettingsTab
**Files:**
- Create: `src/renderer/components/book/BookSettingsTab.tsx`
- Create: `src/renderer/stores/useBookPrefsStore.ts`
- Modify: `src/renderer/components/layout/RightPanel.tsx`
- [x] **Step 1: BookSettingsTab** — 表单字段:
- 书名、分类、目标字数、简介(textarea)
- 封面:按钮选文件 → 存 `userData/covers/{bookId}.jpg` 路径到 meta.coverPath
- 「每次打开显示驾驶舱」checkbox → `bookPrefs.set('alwaysShowCockpit', 'true'|'false')`
- 危险区:删书 → confirm → `book.delete``loadBooks``setView('home')`
- [x] **Step 2: RightPanel**`book-settings` panel 渲染 `<BookSettingsTab />`
- [x] **Step 3: i18n**`bookSettings.*` 键(中英)
---
## Task 6: 分卷右键 + 章节管理 UI
**Files:**
- Create: `src/renderer/components/volume/VolumeContextMenu.tsx`
- Create: `src/renderer/components/chapter/ChapterMetaPanel.tsx`
- Modify: `src/renderer/components/layout/EditorLayout.tsx`
- [x] **Step 1: VolumeContextMenu** — 绑定分卷标题 `onContextMenu`
- 重命名:prompt → `volume.update`
- 编辑简述:prompt → `volume.update({ description })`
- 删除:若卷内无章 → `volume.delete`;有章则 toast 错误
- [x] **Step 2: 章节双击改名**`chapter-item` `onDoubleClick` → input inline → `chapter.update({ title })`
- [x] **Step 3: 章节删除** — 右键或 hover 图标 → confirm → `chapter.delete``refreshChapters`
- [x] **Step 4: ChapterMetaPanel** — 侧栏或状态栏按钮打开:
- summary、story_time 输入
- 标签 chips + 添加输入
- word_count_threshold 数字
- [x] **Step 5: 发布日期**`setPublishStatus` 后本地 chapter 显示 `published_at`(只读)
- [x] **Step 6: 字数阈值变色** — 状态栏 `wordCount.chapter``currentChapter.wordCountThreshold` 比较,超则橙色 class
---
## Task 7: AI 会话菜单
**Files:**
- Create: `src/renderer/components/ai/AiSessionMenu.tsx`
- Modify: `src/renderer/components/ai/AiPanel.tsx`
- Modify: `src/renderer/stores/useAiStore.ts`
- [x] **Step 1: AiSessionMenu** — 会话选择器旁「⋯」按钮:
- 重命名 → `ai.session.update({ title })`
- 归档/取消归档 → `{ archived: true/false }`
- 导出 → 子菜单 txt/md → `ai.session.export`
- 删除 → confirm → `ai.session.delete`
- [x] **Step 2: useAiStore**`loadSessions(bookId, true)` 加载含归档;归档会话 optgroup 或折叠区
- [x] **Step 3: i18n**`ai.session.*`
---
## Task 8: 专注模式 + TTS
**Files:**
- Create: `src/renderer/components/focus/FocusModeOverlay.tsx`
- Create: `src/renderer/lib/tts-controller.ts`
- Modify: `src/renderer/App.tsx`
- Modify: `src/renderer/styles/layout.css`
- [x] **Step 1: FocusModeOverlay**`useAppStore.focusMode` boolean
- true 时 `#app``focus-mode` classCSS 隐藏 sidebars/right-panel/topbar 非必要按钮)
- Esc 或按钮退出
- [x] **Step 2: App.tsx**`focusMode` 动作调用 `setFocusMode(true)`;再次按退出
- [x] **Step 3: tts-controller.ts**
```typescript
export function speakText(text: string, lang = 'zh-CN'): void {
const u = new SpeechSynthesisUtterance(text)
u.lang = lang
window.speechSynthesis.cancel()
window.speechSynthesis.speak(u)
}
export function stopSpeaking(): void {
window.speechSynthesis.cancel()
}
```
- [x] **Step 4: App.tsx**`toggleTTS`:若正在朗读则 stop;否则读当前章 `editor.getText()` 或选区
- [x] **Step 5: i18n**`focus.*` / `tts.*`
---
## Task 9: 占位清零 + 顶栏进度
**Files:**
- Modify: `src/renderer/components/wordfreq/WordFreqPanel.tsx`
- Modify: `src/renderer/components/layout/TopBar.tsx`
- Modify: `src/renderer/stores/useAppStore.ts`
- [x] **Step 1: WordFreqPanel** — 底部按钮 `onClick` 改为 `setNamingOpen(true)` 或触发 KnowledgePanel 命名检查(与 `NamingCheckModal` 联动)
- [x] **Step 2: TopBar** — 在 `top-spacer` 前加细进度条:
-`useWritingStatsStore` + `dailyWordGoal`
- `goalProgress` 映射 width%;≥100% 绿色,≥80% 橙色
- [x] **Step 3: 全局 grep** `feature.comingSoon` — Wave 1 范围内应无功能入口残留
---
## Task 10: E2E + 版本 v1.2.0
**Files:**
- Create: `e2e/book-settings.spec.ts`
- Create: `e2e/chapter-management.spec.ts`
- Create: `e2e/focus-tts.spec.ts`
- Modify: `e2e/chapter-template.spec.ts`
- Modify: `e2e/helpers.ts`
- Modify: `package.json`, `README.md`, `SettingsPage.tsx`
- [x] **Step 1: e2e/helpers.ts**`openBookSettings(page)` helper
- [x] **Step 2: E2E-BOOK-01** — 改书名、勾选 always cockpit、删书
- [x] **Step 3: E2E-CHAP-01** — 双击改标题、删除章
- [x] **Step 4: E2E-FOCUS-01** — Ctrl+Alt+F 进入专注,Esc 退出
- [x] **Step 5: E2E-TEMPL-05** — 模板含 `{characterList}` 的自定义模板(设置页创建)→ 快速新建 → 编辑器含角色名
- [x] **Step 6: 全量验证**
```bash
npm run build
npm run test
npm run test:e2e -- e2e/book-settings.spec.ts e2e/chapter-management.spec.ts e2e/focus-tts.spec.ts e2e/chapter-template.spec.ts
```
- [x] **Step 7: 版本**`package.json` / README / Settings 关于页 → **v1.2.0**
- [x] **Step 8: 勾选本 plan 全部 checkbox**
---
## Plan 自检
| Spec §5 要求 | Task |
|-------------|------|
| 5.1 书籍设置 Tab | Task 5 |
| 5.2 分卷管理 | Task 6 |
| 5.3 章节管理 | Task 6 |
| 5.4 模板变量 | Task 4 |
| 5.5 AI 会话 UI | Task 7 |
| 5.6 快捷键 | Task 8 |
| 5.7 用词分析 | Task 9 |
| 5.8 验收 E2E | Task 10 |
无 TBD 占位。
---
## 后续波次计划(待 W1 完成后编写)
| 文件 | 版本 |
|------|------|
| `docs/superpowers/plans/2026-07-08-bilin-w2-project-pack.md` | v1.3.0 |
| `docs/superpowers/plans/2026-07-08-bilin-w3-export-read.md` | v1.4.0 |
| `docs/superpowers/plans/2026-07-08-bilin-w4-creative-depth.md` | v1.5.0 |
| `docs/superpowers/plans/2026-07-08-bilin-w5-platform.md` | v2.0.0 |
@@ -0,0 +1,157 @@
# 笔临 Wave 2 项目包 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 (`- [ ]`) syntax for tracking.
**Goal:** 交付 v1.3.0`.novel` 单书项目包与 `.novel-all` 批量包导入导出,主页/设置/导出模态接入,大文件进度与取消。
**Architecture:** `pack-bootstrap` 独立 chunk 动态 `import('archiver')``PackService` 读写 ZIP + `manifest.json` v1;导出复制 `book.sqlite` + `meta.json` + 可选 `attachments/`;导入默认新 UUID 注册;`PACK_PROGRESS` IPC 推送进度;>500MB 导出前确认。
**Tech Stack:** Electron 36、archiver、node:fs、TypeScript、Vitest、Playwright
**Spec:** `docs/superpowers/specs/2026-07-08-bilin-master-completion-design.md` §4.6、§6
**Master Program:** Wave 2/5 → v1.3.0
---
## File Map
| 文件 | 职责 |
|------|------|
| `src/shared/novel-pack.ts` | manifest v1 类型、formatVersion 常量 |
| `src/main/pack-bootstrap.ts` | 延迟加载 archiver,注册 pack IPC |
| `src/main/services/pack.service.ts` | exportBook/importBook/exportAll/importAll |
| `src/main/ipc/handlers/pack.handler.ts` | PACK_* IPC |
| `src/shared/ipc-channels.ts` | PACK_EXPORT_BOOK 等 |
| `src/shared/types.ts` | PackProgressEvent、PackImportReport |
| `src/preload/index.ts` + `electron-api.d.ts` | pack.export/import API |
| `src/renderer/components/export/ExportAllModal.tsx` | 主页导出全部 |
| `src/renderer/components/import/ImportBookModal.tsx` | 支持 .novel/.novel-all |
| `src/renderer/components/export/ExportModal.tsx` | 增加 .novel 选项 |
| `src/renderer/components/home/HomePage.tsx` | 导出全部入口 |
| `src/renderer/components/settings/BackupSettingsTab.tsx` | 设置页备份区 |
| `electron.vite.config.ts` | pack-bootstrap 入口 |
| `tests/main/pack.service.test.ts` | IT-ALL-02 等 |
| `e2e/pack-export-import.spec.ts` | 单书 .novel E2E |
| `e2e/pack-export-all.spec.ts` | E2E-ALL-03 |
---
## Task 1: 依赖 + 类型 + pack-bootstrap
**Files:**
- Modify: `package.json`
- Create: `src/shared/novel-pack.ts`
- Create: `src/main/pack-bootstrap.ts`
- Modify: `electron.vite.config.ts`
- [x] **Step 1:** `npm install archiver` + dev `@types/archiver`
- [x] **Step 2:** `novel-pack.ts``NOVEL_FORMAT_VERSION = 1``NovelManifest``NovelAllManifest`
- [x] **Step 3:** `pack-bootstrap.ts` — 导出 `bootstrapPackHandlers(registry, settings)`
- [x] **Step 4:** `electron.vite.config.ts` — 增加 `pack-bootstrap` input`external: ['archiver']`
- [x] **Step 5:** `npm run build` 通过
---
## Task 2: PackService 单书导出/导入
**Files:**
- Create: `src/main/services/pack.service.ts`
- Modify: `src/main/services/book-registry.ts``importFromDb(meta, sqlitePath)` 辅助
- [x] **Step 1:** `exportBook(bookId, destPath)` — ZIP: manifest + meta + book.sqlite + attachments
- [x] **Step 2:** `importBook(filePath)` — 校验 formatVersion,新 UUID,注册 meta,复制 sqlite
- [x] **Step 3:** 名称冲突时追加后缀 `(2)`
- [x] **Step 4:** UT `tests/main/pack.service.test.ts` — 导出再导入章节数一致
---
## Task 3: exportAll / importAll + 进度
**Files:**
- Modify: `src/main/services/pack.service.ts`
- Modify: `src/shared/types.ts`
- [x] **Step 1:** `exportAll(destPath)` — global_settings + books/{id}/
- [x] **Step 2:** `importAll(filePath, strategy)` — skip/overwrite/new
- [x] **Step 3:** `onProgress` 回调 + `PACK_PROGRESS` 广播
- [x] **Step 4:** `estimateSize` + >500MB 确认标志
- [x] **Step 5:** AbortController 取消支持
- [x] **Step 6:** UT IT-ALL-02 — 3 书导出删库再导入
---
## Task 4: IPC + preload
**Files:**
- Create: `src/main/ipc/handlers/pack.handler.ts`
- Modify: `src/main/pack-bootstrap.ts`
- Modify: `src/main/index.ts`
- Modify: `src/preload/index.ts``electron-api.d.ts`
- [x] **Step 1:** IPC 常量与 handler 注册
- [x] **Step 2:** preload `pack.exportBook/importBook/exportAll/importAll/pickFile`
- [x] **Step 3:** `onPackProgress` 事件订阅
---
## Task 5: ExportModal + ImportBookModal
**Files:**
- Modify: `src/renderer/components/export/ExportModal.tsx`
- Modify: `src/renderer/components/import/ImportBookModal.tsx`
- Modify: `public/locales/*/translation.json`
- [x] **Step 1:** ExportModal 增加「笔临项目包 (.novel)」
- [x] **Step 2:** ImportBookModal 文件过滤含 `.novel`/`.novel-all`
- [x] **Step 3:** `.novel` 直接导入;`.novel-all` 打开策略选择 + 进度条
---
## Task 6: ExportAllModal + HomePage
**Files:**
- Create: `src/renderer/components/export/ExportAllModal.tsx`
- Modify: `src/renderer/components/home/HomePage.tsx`
- Modify: `src/renderer/stores/useAppStore.ts`(可选 modal 状态)
- [x] **Step 1:** `ExportAllModal` — 选路径、大小确认、进度、取消
- [x] **Step 2:** HomePage「导出所有书籍」启用并打开 modal
---
## Task 7: 设置页备份区
**Files:**
- Create: `src/renderer/components/settings/BackupSettingsTab.tsx`
- Modify: `src/renderer/components/settings/SettingsPage.tsx`
- [x] **Step 1:** 导出当前书 / 导出全部按钮
- [x] **Step 2:** i18n `backup.*`
---
## Task 8: E2E + v1.3.0
**Files:**
- Create: `e2e/pack-export-import.spec.ts`
- Create: `e2e/pack-export-all.spec.ts`
- Modify: `e2e/helpers.ts`
- Modify: `package.json``README.md``SettingsPage.tsx`
- [x] **Step 1:** E2E 单书 .novel 导出再导入
- [x] **Step 2:** E2E-ALL-03 主页导出全部
- [x] **Step 3:** 全量 `npm run test` + 相关 E2E
- [x] **Step 4:** 版本 **v1.3.0**
- [x] **Step 5:** 勾选本 plan 全部 checkbox
---
## Plan 自检
| Spec §6 要求 | Task |
|-------------|------|
| PackService 四 API | Task 23 |
| 进度 IPC | Task 34 |
| UI 入口 | Task 57 |
| IT-ALL-02 / E2E | Task 8 |
@@ -0,0 +1,137 @@
# 笔临 Wave 3 导出阅读 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 (`- [ ]`) syntax for tracking.
**Goal:** 交付 v1.4.0:导出矩阵(txt/md/docx/pdf)、连续阅读模式、驾驶舱补全、专注环境音。
**Architecture:** `ExportMatrixService` 扩展 `ExportService` 模式;docx 用 `docx` 库;pdf 用 Electron `printToPDF` + HTML 模板;`ReadModeOverlay` 虚拟 3 章 + scroll-snap;阅读偏好写入 `GlobalSettings`
**Tech Stack:** Electron 36、docx、TypeScript、Vitest、Playwright
**Spec:** `docs/superpowers/specs/2026-07-08-bilin-master-completion-design.md` §7
**Master Program:** Wave 3/5 → v1.4.0
---
## File Map
| 文件 | 职责 |
|------|------|
| `src/shared/export-matrix.ts` | ExportFormat/Scope/Options 类型 |
| `src/main/services/export-matrix.service.ts` | txt/md/docx/pdf 导出 |
| `src/main/export-matrix-bootstrap.ts` | 延迟加载 docx,注册 IPC |
| `src/main/ipc/handlers/export-matrix.handler.ts` | EXPORT_MATRIX_* |
| `src/renderer/components/read/ReadModeOverlay.tsx` | 连续阅读全屏 |
| `src/renderer/stores/useReadModeStore.ts` | 阅读模式状态 |
| `src/renderer/components/export/ExportModal.tsx` | 导出矩阵 UI |
| `src/renderer/components/cockpit/CockpitModal.tsx` | 今日大纲任务 |
| `src/renderer/components/focus/FocusModeOverlay.tsx` | 环境音效 |
| `e2e/read-mode.spec.ts` | E2E-READ-01~03 |
| `e2e/export-matrix.spec.ts` | E2E-PDF-01 等 |
---
## Task 1: 类型 + 阅读偏好
**Files:**
- Create: `src/shared/export-matrix.ts`
- Modify: `src/shared/types.ts``readModeFontTheme``readModeDeviceWidth``enableFocusAmbientSound`
- [x] **Step 1:** export-matrix 类型定义
- [x] **Step 2:** GlobalSettings 阅读/专注字段
- [x] **Step 3:** settings store 默认值
---
## Task 2: 连续阅读模式
**Files:**
- Create: `src/renderer/stores/useReadModeStore.ts`
- Create: `src/renderer/components/read/ReadModeOverlay.tsx`
- Modify: `EditorLayout.tsx``VolumeContextMenu.tsx``App.tsx``layout.css`
- [x] **Step 1:** ReadModeOverlay 全屏 + 虚拟 3 章 + scroll-snap
- [x] **Step 2:** 设备宽度 / 字体主题切换与持久化
- [x] **Step 3:** 章节侧栏 + 分卷右键入口
- [x] **Step 4:** i18n `readMode.*`
---
## Task 3: ExportMatrixService txt/md
**Files:**
- Create: `src/main/services/export-matrix.service.ts`
- Create: `src/main/ipc/handlers/export-matrix.handler.ts`
- [x] **Step 1:** 章/卷/全书范围解析
- [x] **Step 2:** txt/md 导出 + 选项(批注/AI 标注)
- [x] **Step 3:** UT 章级 txt 导出
---
## Task 4: docx 导出
**Files:**
- Modify: `package.json``docx`
- Modify: `export-matrix.service.ts`
- [x] **Step 1:** docx 目录与章节结构
- [x] **Step 2:** IT-DOCX-01 含目录
---
## Task 5: pdf 导出
**Files:**
- Modify: `export-matrix.service.ts`
- [x] **Step 1:** HTML 模板 + printToPDF
- [x] **Step 2:** A4 页眉页脚
- [x] **Step 3:** E2E mock 路径 BILIN_E2E_PDF_SAVE
---
## Task 6: ExportModal 矩阵 UI
**Files:**
- Modify: `ExportModal.tsx``preload``ipc-channels`
- [x] **Step 1:** 格式/范围/选项选择
- [x] **Step 2:** 保存对话框与 toast
---
## Task 7: 驾驶舱 + 专注环境音
**Files:**
- Modify: `CockpitModal.tsx`
- Modify: `FocusModeOverlay.tsx``SettingsPage.tsx`
- [x] **Step 1:** 今日大纲任务 Top 5pending + expectedWordCount
- [x] **Step 2:** 专注模式环境音开关与播放
---
## Task 8: E2E + v1.4.0
**Files:**
- Create: `e2e/read-mode.spec.ts``e2e/export-matrix.spec.ts`
- Modify: `package.json``README.md``SettingsPage.tsx`
- [x] **Step 1:** E2E-READ-01~03
- [x] **Step 2:** E2E-PDF-01
- [x] **Step 3:** 全量 test + 相关 E2E
- [x] **Step 4:** 版本 **v1.4.0**
- [x] **Step 5:** 勾选本 plan 全部 checkbox
---
## Plan 自检
| Spec §7 要求 | Task |
|-------------|------|
| ExportMatrix 四格式 | Task 36 |
| ReadModeOverlay | Task 2 |
| 驾驶舱大纲任务 | Task 7 |
| E2E-READ / E2E-PDF | Task 8 |
@@ -0,0 +1,80 @@
# 笔临 Wave 4 创作深化 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 (`- [ ]`) syntax for tracking.
**Goal:** 交付 v1.5.0:故事时间线、角色弧线、敏感词合规、引用抽屉与章悬停预览、自定义快捷命令。
**Architecture:** `timeline_events` 表 + 章节 `story_time` 聚合;`ArcService` 聚合 knowledge 角色状态;`ComplianceService` 本地词库 + TipTap DecorationGlobalSettings 扩展合规/命令字段。
**Tech Stack:** Electron 36、TipTap、TypeScript、Vitest、Playwright
**Spec:** `docs/superpowers/specs/2026-07-08-bilin-master-completion-design.md` §8
**Master Program:** Wave 4/5 → v1.5.0
---
## File Map
| 文件 | 职责 |
|------|------|
| `src/shared/timeline.ts` | TimelineEntry、TimelineConflict |
| `src/main/db/schema-v10.sql` | timeline_events |
| `src/main/db/repositories/timeline.repo.ts` | CRUD |
| `src/main/services/timeline.service.ts` | 列表、冲突检测 |
| `src/main/ipc/handlers/timeline.handler.ts` | TIMELINE_* |
| `src/renderer/components/timeline/TimelineModal.tsx` | 时间轴 UI |
| `src/main/services/arc.service.ts` | 角色弧线节点 |
| `src/renderer/components/setting/CharacterArcPanel.tsx` | 弧线 Tab |
| `src/main/services/compliance.service.ts` | 词库扫描 |
| `src/renderer/extensions/compliance-highlight.ts` | 下划线装饰 |
| `e2e/timeline.spec.ts` | E2E-TL-01~03 |
---
## Task 1: Schema v10 + 类型
- [x] **Step 1:** schema-v10 + migrate v10
- [x] **Step 2:** `timeline.ts` 类型
- [x] **Step 3:** migrate UT 更新
---
## Task 2: Timeline 服务 + UI
- [x] **Step 1:** TimelineRepository + TimelineService
- [x] **Step 2:** IPC + preload
- [x] **Step 3:** TimelineModal + EditorLayout 入口
- [x] **Step 4:** 冲突检测 + 红色标记
---
## Task 3: 角色弧线
- [x] **Step 1:** ArcService + ARC_GET IPC
- [x] **Step 2:** CharacterArcPanel 于 character 设定
- [x] **Step 3:** UT 弧线节点
---
## Task 4: 敏感词合规
- [x] **Step 1:** ComplianceService + IPC
- [x] **Step 2:** TipTap compliance-highlight
- [x] **Step 3:** 设置页开关 + UT 下划线
---
## Task 5: 其他补全
- [x] **Step 1:** 引用面板详情抽屉
- [x] **Step 2:** 章节悬停预览
- [x] **Step 3:** customSlashCommands 设置编辑
---
## Task 6: E2E + v1.5.0
- [x] **Step 1:** E2E-TL-01~03
- [x] **Step 2:** 全量 test + 相关 E2E
- [x] **Step 3:** 版本 **v1.5.0**
@@ -0,0 +1,78 @@
# 笔临 Wave 5 平台基建 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 (`- [ ]`) syntax for tracking.
**Goal:** 交付 v2.0.0:撤销堆栈持久化、云同步、崩溃日志、自动更新、扩展 API 骨架、Skill/MCP 占位。
**Architecture:** Schema v11 `undo_stack``SyncService` 加密 `.novel-all``electron-log` + `electron-updater``ExtensionRegistry` stub。
**Tech Stack:** Electron 36、electron-log、electron-updater、Vitest、Playwright
**Spec:** `docs/superpowers/specs/2026-07-08-bilin-master-completion-design.md` §9
**Master Program:** Wave 5/5 → v2.0.0
---
## File Map
| 文件 | 职责 |
|------|------|
| `src/main/db/schema-v11.sql` | undo_stack |
| `src/main/services/undo-stack.service.ts` | 持久化撤销 |
| `src/main/services/sync.service.ts` | 本地/WebDAV 同步 |
| `src/main/services/log.service.ts` | electron-log |
| `src/main/services/update.service.ts` | electron-updater |
| `src/main/services/extension-registry.ts` | 扩展注册表 stub |
| `src/shared/extension-api.ts` | BilinExtension 类型 |
| `src/renderer/components/settings/SyncSettingsTab.tsx` | 备份与同步 |
| `e2e/timeline.spec.ts` | E2E-TL-01~03 |
---
## Task 1: Schema v11 + 撤销堆栈
- [x] **Step 1:** schema-v11 + migrate v11
- [x] **Step 2:** UndoStackRepository + Service + IPC
- [x] **Step 3:** TipTap 防抖持久化 + 跨会话 Ctrl+Z
- [x] **Step 4:** E2E-UNDO-01
---
## Task 2: 云同步
- [x] **Step 1:** sync-crypto AES-256-GCM
- [x] **Step 2:** SyncService local-folder + WebDAV
- [x] **Step 3:** SyncSettingsTab + 冲突模态
- [x] **Step 4:** IT-SYNC-01 + E2E-SYNC-01
---
## Task 3: 日志与崩溃报告
- [x] **Step 1:** LogService + 启动检测
- [x] **Step 2:** 发送报告 stub + UT-LOG-01
---
## Task 4: 自动更新
- [x] **Step 1:** UpdateService + IPC 事件
- [x] **Step 2:** 顶栏更新提示(BILIN_E2E 禁用 / BILIN_MOCK_UPDATE mock
- [x] **Step 3:** IT-UPDATE-01 + E2E-UPDATE-02
---
## Task 5: 扩展 API + Skill/MCP
- [x] **Step 1:** extension-api.ts + registry stub
- [x] **Step 2:** docs/extensions/README.md
- [x] **Step 3:** ExtensionsSettingsTab + AI Skill/MCP 占位
---
## Task 6: i18n + v2.0.0
- [x] **Step 1:** app.getLocale 首次语言
- [x] **Step 2:** 全量 test + E2E
- [x] **Step 3:** 版本 **v2.0.0**
@@ -0,0 +1,623 @@
# 笔临 Master Completion 全量对齐设计规格
**版本**1.0
**日期**2026-07-08
**范围**`design/readme.md` v1.1 全部未实现/占位/部分实现能力,分 5 波连续交付至 100% 对齐
**前置**v1.1.0 已交付(`adf8778`:模板 + txt/md/docx 导入 + 投稿导出 + 全局灵感)
**参考**`design/readme.md` 全文、`design/ui_pc.html`、v1.1 spec §1.4 推迟项
**目标版本**v1.2.0 → v1.3.0 → v1.4.0 → v1.5.0 → v2.0.0(五波合一 Master Program
---
## 1. 背景与目标
### 1.1 阶段定位
v1.1.0 完成作家日更工具包后,代码库与 `design/readme.md` 仍存在:
- **5 处硬占位**`comingSoon` / placeholder-box
- **v1.1 明确推迟的 9 类能力**(项目包、导出矩阵、时间线、阅读模式等)
- **~15 个零代码大模块**(云同步、自动更新、撤销堆栈、扩展 API 等)
- **大量 IPC 已有、UI 缺失**(删书、分卷管理、会话归档等)
- **部分实现与设计差距**(章节标签、发布日期、模板变量自动填充等)
Brainstorming 2026-07-08 用户确认 **方案 A:全量对齐**,包含 Wave 5 平台基建。
### 1.2 已确认决策
| 维度 | 决策 |
|------|------|
| 交付策略 | **Master Program 五波连续交付**(非单版本大爆炸) |
| 范围 | Waves 15 全做,对齐 `design/readme.md` |
| 永久排除 | 平台 API 直连投稿(YAGNI |
| 可选排除 | SQLCipher 本地库加密(§12.4 标注可选项,本 Program **不做** |
| 大依赖拆分 | 仿 v1.1 `import-bootstrap``pack-bootstrap`archiver)、`pdf-bootstrap`puppeteer 或 pdf-lib |
| `.novel` 格式 | 自研 ZIP + `manifest.json` v1,带 `appVersion` 兼容检查 |
| PDF 方案 | **Puppeteer** 渲染 HTML → PDF(对齐设计 §11.2.2 页眉页脚);开发/E2E 用 mock 或 skip |
| docx 导出 | **docx** npm 包或 HTML→docx 转换 |
| 云同步 | WebDAV + 本地目录双模式;AES-256-GCM + PBKDF2 派生密钥 |
| 自动更新 | `electron-updater`;开发模式禁用;无代码签名时保留 IPC 通道 |
| 扩展 API | **骨架 + 类型 + 注册表 + 文档**;不实现完整插件市场/沙箱加载 |
| Skill/MCP | 设置页展示内置 Skill 列表 + MCP 连接器 **配置占位** + 接口类型预留 |
| 撤销堆栈 | 每章最多 50 条,存 `undo_stack` 表;与 TipTap History 协同 |
| 测试 | 每波补齐 design 对应用例;AI 相关 IT 240s / E2E 300s |
### 1.3 总体验收标准
Master Program 完成后:
1. 代码库中 **无** `feature.comingSoon` 用于真实功能入口(仅文档/关于页说明性文案除外)
2. `design/readme.md` 各章测试表用例 **均有对应 UT/IT/E2E**(PDF/更新/同步可用 mock 或集成桩)
3. 用户可完成:书籍全生命周期管理、项目包备份恢复、多格式导出、连续阅读、时间线、合规检查、云同步、崩溃日志、检查更新
4. README 版本号升至 **v2.0.0**
### 1.4 明确不实现
| 项 | 原因 |
|----|------|
| 平台后台 API 直连投稿 | 永久 YAGNI |
| SQLCipher 数据库加密 | §12.4 可选,复杂度高 |
| 完整第三方插件市场 / 动态加载未签名扩展 | Wave 5 仅骨架 |
| 崩溃报告上传到远端 API | 保留按钮,调用 stub |
| macOS/Linux 代码签名 | 保留渠道,不实施签名 |
---
## 2. 波次规划
```
Wave 1 (v1.2.0) 基础治理 — 占位清零 + 书籍/章节/分卷/AI 会话 UI 补全
Wave 2 (v1.3.0) 项目包 — .novel / .novel-all 导入导出
Wave 3 (v1.4.0) 导出阅读 — 导出矩阵 + 连续阅读 + 专注/TTS + 驾驶舱补全
Wave 4 (v1.5.0) 创作深化 — 时间线 + 角色弧线 + 敏感词 + AI 文风/附件/骨架
Wave 5 (v2.0.0) 平台基建 — 撤销堆栈 + 云同步 + 日志 + 自动更新 + 扩展 API
```
**依赖**W1 → W2 → W3W1 → W4W2 → W5(同步依赖 .novel);W3/W4 → W5。
---
## 3. 架构
### 3.1 进程模型(目标态)
```
┌──────────────────────────────────────────────────────────────────────────┐
│ 渲染进程 │
│ BookSettingsTab / ReadModeView / TimelineView / CharacterArcTab │
│ ExportModal(扩展)/ ExportAllModal / SyncSettingsTab │
│ FocusMode overlay / TTS controller / StyleMemoryPanel │
│ ExtensionRegistry (types only, dev docs) │
└────────────────────────────┬─────────────────────────────────────────────┘
│ IPC
┌────────────────────────────▼─────────────────────────────────────────────┐
│ 主进程 index.js(核心) │
│ BookRegistry / GlobalSettings / 现有 handlers │
├────────────────────────────────────────────────────────────────────────────┤
│ 独立 bootstrap chunks(按需 dynamic import
│ · import-bootstrap.js (已有) │
│ · pack-bootstrap.js archiver, .novel/.novel-all pack/unpack │
│ · export-bootstrap.js docx, pdf, batch export │
│ · sync-bootstrap.js webdav-client, crypto encrypt │
└────────────────────────────────────────────────────────────────────────────┘
```
### 3.2 模块边界(新增)
| 模块 | 职责 | 进程 |
|------|------|------|
| `PackService` | `.novel` / `.novel-all` 打包与解包 | main (pack-bootstrap) |
| `ExportMatrixService` | txt/md/docx/pdf/批量 | main (export-bootstrap) |
| `ReadModeService` | 拼合卷章节纯文本、虚拟分页元数据 | main |
| `TimelineService` | `timeline_events` CRUD、冲突检测 | main |
| `ComplianceService` | 词库匹配、导出前扫描 | main |
| `CharacterArcService` | 从 knowledge 聚合角色节点 | main |
| `StyleMemoryService` | 章节抽样分析文风特征 | main |
| `UndoStackService` | 持久化 ProseMirror steps JSON | main |
| `SyncService` | 加密上传/下载 .novel | main (sync-bootstrap) |
| `LogService` | electron-log 初始化、崩溃检测 | main |
| `UpdateService` | electron-updater 封装 | main |
| `ExtensionRegistry` | 扩展点类型与空注册表 | shared + main stub |
### 3.3 构建配置
`electron.vite.config.ts` 增加入口:
- `pack-bootstrap`
- `export-bootstrap`
- `sync-bootstrap`
`rollupOptions.external` 包含:`archiver``puppeteer`(或 `pdf-lib`)、`docx``webdav`(或自实现 fetch WebDAV)。
`src/main/index.ts``app.whenReady`**不** 预加载所有 bootstrap;各 handler 首次调用时 `import()` 对应 chunk(与 import 模式一致)。
---
## 4. 数据模型
### 4.1 Schema v9Wave 12
**`book_preferences` 扩展**(已有表):
```sql
ALTER TABLE book_preferences ADD COLUMN always_show_cockpit INTEGER NOT NULL DEFAULT 0;
ALTER TABLE book_preferences ADD COLUMN cover_path TEXT DEFAULT NULL;
ALTER TABLE book_preferences ADD COLUMN synopsis TEXT DEFAULT '';
```
**`chapters` 扩展**(部分列 migrate 已有):
```sql
-- 已有: summary, story_time, publish_status
ALTER TABLE chapters ADD COLUMN published_at TEXT DEFAULT NULL;
ALTER TABLE chapters ADD COLUMN word_count_threshold INTEGER DEFAULT NULL;
```
**`chapter_tags` 新表**(设计 readme 已有 DDL):
```sql
CREATE TABLE IF NOT EXISTS chapter_tags (
chapter_id TEXT NOT NULL,
tag TEXT NOT NULL,
color TEXT DEFAULT '',
PRIMARY KEY (chapter_id, tag),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
```
**`volumes` 扩展**
```sql
-- description 列若不存在则 ADD
```
**`BookMeta`registry JSON)扩展**
```typescript
interface BookMeta {
// 已有字段
coverPath?: string | null
synopsis?: string
}
```
### 4.2 Schema v10Wave 4
```sql
CREATE TABLE IF NOT EXISTS timeline_events (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL, -- 冗余便于查询;实际存 book db 内可不存 book_id
kind TEXT NOT NULL, -- chapter | event | conflict
title TEXT NOT NULL,
description TEXT DEFAULT '',
story_time TEXT NOT NULL, -- 绝对或相对表达式
chapter_id TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE SET NULL
);
```
> 注:`timeline_events` 存于**每书 SQLite**(与 chapters 同库),非全局库。
### 4.3 Schema v11Wave 4
```sql
CREATE TABLE IF NOT EXISTS compliance_words (
id TEXT PRIMARY KEY,
word TEXT NOT NULL UNIQUE,
builtin INTEGER NOT NULL DEFAULT 0
);
```
全局词库可放 `global_settings.json``complianceWords: string[]`;内置词库用 JSON 文件 `src/shared/builtin-compliance-words.json`
### 4.4 Schema v12Wave 5
```sql
CREATE TABLE IF NOT EXISTS undo_stack (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chapter_id TEXT NOT NULL,
operation TEXT NOT NULL,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
```
### 4.5 GlobalSettings 扩展
```typescript
interface GlobalSettings {
// 已有
complianceCheckEnabled?: boolean
syncConfig?: SyncConfig | null
customSlashCommands?: { trigger: string; template: string }[]
styleMemoryPresets?: StylePreset[]
readModeFontTheme?: 'serif' | 'sans' | 'eye-care'
readModeDeviceWidth?: 'phone' | 'tablet' | 'desktop'
extensionDevMode?: boolean
}
interface SyncConfig {
mode: 'webdav' | 'local-folder'
webdavUrl?: string
webdavUser?: string
localFolderPath?: string
schedule: 'manual' | 'hourly' | 'daily' | 'weekly'
lastSyncAt?: string | null
lastSyncStatus?: 'ok' | 'error' | 'syncing'
}
interface SubmissionPreset {
// 已有字段
sensitiveReplacements?: { from: string; to: string }[]
}
```
### 4.6 `.novel` 包格式 v1
```
book.novel (ZIP)
├── manifest.json
│ {
│ "formatVersion": 1,
│ "appVersion": "2.0.0",
│ "exportedAt": "ISO8601",
│ "bookId": "uuid",
│ "bookName": "书名"
│ }
├── meta.json # BookMeta 快照
├── book.sqlite # 完整书籍 DB
└── attachments/ # 封面等(可选)
```
**`.novel-all` v1**
```
archive.novel-all (ZIP)
├── manifest.json # bookCount, appVersion, exportedAt
├── global_settings.json
└── books/
└── {bookId}/
├── meta.json
└── book.sqlite
```
**导入策略**
- `.novel`:默认新建书(新 UUID),`meta.name` 可冲突时追加后缀
- `.novel-all`:合并导入;`bookId` 冲突时提示跳过/覆盖/新建
- `formatVersion` 不匹配时拒绝并提示升级应用
---
## 5. Wave 1 — 基础治理(v1.2.0
### 5.1 书籍设置 Tab
替换 `RightPanel` `book-settings` 占位为 `BookSettingsTab`
- 书名(只读或改 meta)、分类、目标字数、封面路径、简介
- 「每次打开显示驾驶舱」开关 → `book_preferences.always_show_cockpit`
- 删书按钮(二次确认)→ `book.delete`
- IPC`book.updateMeta`
### 5.2 分卷管理
- 分卷右键菜单:重命名、删除(章节移入默认卷或一并删除—**默认:禁止删除非空卷,需先移章**)
- 分卷简述编辑
- `volume.update` / `volume.delete` UI 接入
### 5.3 章节管理补全
- 双击章节标题 inline 编辑 → `chapter.update`
- 章节删除(右键或图标)→ `chapter.delete`
- 章节标签 CRUD + 侧栏按标签筛选
- `summary` / `story_time` 字段:章节属性面板或状态栏入口
- 发布状态切换时,若变为 `published` 写入 `published_at = now()`
- 可选每章 `word_count_threshold`;状态栏字数变色(绿/橙规则同 design §5.2
### 5.4 模板变量
`TemplateQuickCreateModal.buildContext`
- `{outlineItem}`:当前卷中关联 `chapterId` 的大纲条目标题,无则 `''`
- `{characterList}`:从 `settings` 中 type=character 且 `setting_chapter_refs` 含当前章(或 POV 角色)拼接名称
### 5.5 AI 会话 UI
`AiPanel` 会话栏增加:
- 重命名(inline 或模态)
- 归档 / 取消归档(`ai.session.update { archived: true }`
- 导出为 `.txt` / `.md`(主进程写文件对话框)
- 删除会话(确认)
### 5.6 快捷键落地
| 快捷键 | 实现 |
|--------|------|
| `focusMode` Ctrl+Alt+F | 全屏隐藏侧栏/右栏,Esc 退出;`#app.focus-mode` CSS |
| `toggleTTS` Ctrl+Shift+R | 朗读当前章选中段落或全文;Web Speech API / 系统 TTS |
### 5.7 用词分析
- 底部「AI 命名建议」→ 打开已有 `NamingCheckModal`(非 comingSoon
### 5.8 Wave 1 验收
- [ ] 无 Wave 1 范围内 comingSoon
- [ ] E2E-TEMPL-05 `{characterList}` 通过
- [ ] 书籍设置/删书/分卷/章删改 E2E 各 1
---
## 6. Wave 2 — 项目包(v1.3.0
### 6.1 PackService
- `pack.exportBook(bookId, destPath)``.novel`
- `pack.importBook(filePath)` → newBookId
- `pack.exportAll(destPath)``.novel-all`
- `pack.importAll(filePath, strategy)` → 导入报告
- 进度 IPC`PACK_PROGRESS { current, total, phase }`
- >500MB 导出前估算并确认(§11.2.3)
### 6.2 UI
- `HomePage`「导出所有书籍」→ `ExportAllModal`
- `ExportModal` 增加「笔临项目包 .novel」选项
- `ImportBookModal` 支持 `.novel` 文件过滤
- 设置页「备份」区:手动导出当前书/全部
### 6.3 大文件
- 导入 `.novel-all` 显示进度条,支持取消(AbortController
### 6.4 Wave 2 验收
- [ ] IT-ALL-02:导出 3 书 → 删库 → 导入恢复
- [ ] E2E-ALL-03:主页导出全部
- [ ] E2E:单书 .novel 导出再导入
---
## 7. Wave 3 — 导出矩阵与阅读(v1.4.0)
### 7.1 ExportMatrixService
| 格式 | 范围 | 技术 |
|------|------|------|
| txt | 章/卷/全书 | 已有 formatter 扩展 |
| md | 章/卷/全书 | marked 逆向或模板 |
| docx | 章/卷/全书 | docx 库 + 目录 |
| pdf | 章/卷/全书 | HTML 模板 + puppeteer |
| 投稿复制 | 单章 | 已有 |
**导出选项**(模态复选框):
- 包含批注/地标
- 标注 AI 生成内容(查 snapshot type=ai
- 历史版本导出为独立文件(可选 zip 内多文件)
### 7.2 连续阅读模式
- 顶栏 👁 → `ReadModeOverlay` 全屏
- 选择当前卷;虚拟 3 章窗口;scroll-snap 翻页
- 设备宽度:360 / 768 / 100%
- 字体主题:serif / sans / eye-care`readModeFontTheme` 持久化)
- 分卷右键「阅读模式」
- Esc 退出
### 7.3 专注模式与顶栏进度
- `focusMode` 快捷键(W1 已有)增加可选环境音效(设置开关,本地 wav)
- 顶栏 `dailyWordGoal` 进度细条(与 EditorLayout 状态栏同源 `writingStats`
### 7.4 驾驶舱补全
- `always_show_cockpit` 书籍偏好生效
- 快速入口「今日大纲任务」:列出 status=pending 且 expected_word_count 的大纲 Top 5
### 7.5 Wave 3 验收
- [ ] E2E-PDF-01mock puppeteer 或 headless 短超时)
- [ ] E2E-READ-01~03
- [ ] 导出 docx 含目录 IT
---
## 8. Wave 4 — 创作深化(v1.5.0
### 8.1 故事时间线
- `TimelineView` 模态或侧栏全页;Canvas 或 CSS 时间轴
- 章节 `story_time` 自动出现在轴上;手动添加关键事件
- 相对时间 `+Nd` 解析;角色出生日期来自 `settings.properties.birthDate`
- 冲突检测:年龄不一致标红(规则见 design §5.7.1
- 点击跳转章节
### 8.2 角色弧线简报
- 设定面板 character 类型 → Tab「弧线」
- 聚合 `knowledge_entries` 中该角色 state 变更 + 关联章节
- 驾驶舱角色摘要可下钻
### 8.3 敏感词合规
- 设置 → 编辑器 → 合规检查开关
- 内置 + 用户词库;编辑器装饰下划线(TipTap Decoration
- `SubmissionPreset.sensitiveReplacements` 导出时替换
- 导出前可选扫描确认对话框
### 8.4 AI 扩展
| 功能 | 实现要点 |
|------|----------|
| 文风记忆 §6.3 | 分析选中范围章节 → 特征 JSON → 滑杆 UI → 保存预设 |
| 自定义快捷命令 §6.2 | `GlobalSettings.customSlashCommands` + 设置编辑 |
| 大纲展开骨架 §6.6 | 大纲项右键「展开为章节骨架」→ AI 生成多章标题+摘要 → 批量 create |
| 附件上传 §6.10 | AI 上下文增加 `attachments[]`;文件存 userData/attachments/{bookId}/ |
### 8.5 其他补全
- 引用面板点击展开详情抽屉
- 章节列表悬停预览(首段 + summary)
- 分卷/章字数阈值提醒
### 8.6 Wave 4 验收
- [ ] E2E-TL-01~03
- [ ] 合规词下划线 UT
- [ ] 弧线 Tab 有数据时展示节点
---
## 9. Wave 5 — 平台基建(v2.0.0
### 9.1 撤销堆栈
- TipTap `editor.on('update')` 防抖写入 `undo_stack`JSON step
- 启动章时加载最近 50 条;与内存 history 合并策略:**仅持久化 manual 断点或每 N 步**
- 超出 50 删最旧
### 9.2 云同步
- 设置页「备份与同步」Tab`SyncSettingsTab`
- WebDAVPUT/GET 加密 `.novel`;本地目录:复制加密文件
- 定时:`node-cron``setInterval`hourly/daily/weekly
- 状态栏同步图标;冲突时模态三选一(本地/远程/取消)
- 首次配置同步密码(≥8 位),密钥不落盘明文
### 9.3 崩溃报告与日志
- `electron-log``{userData}/logs/bilin.log`,保留 7 天
- `app.getLastCrashReport` 或标记异常退出;下次启动提示
- 「发送报告」→ stub 记录日志
### 9.4 自动更新
- `electron-updater``feedURL` 来自环境或 package.json publish 配置
- IPC`update:check` / `update:download` / 事件推送
- 顶栏或状态栏「新版本可用」;开发模式 `BILIN_E2E` 禁用
### 9.5 扩展 API 骨架
- `src/shared/extension-api.ts``BilinExtension` 类型(同 design §15.3
- `src/main/services/extension-registry.ts`:空注册表 + `registerImporter` 等 stub
- `docs/extensions/README.md`:贡献者文档
- 设置页「扩展」:列出已声明扩展(dev 模式可从文件夹加载 manifest 校验,**不执行**第三方代码)
### 9.6 Skill / MCP 占位
- 设置 → AI → 内置 Skill 列表(静态 JSON
- MCP 连接器 URL 配置字段 + 测试连接 stub
### 9.7 无障碍与 i18n
- 关键模态 `aria-*` 复查;键盘 Tab 顺序
- 首次启动语言默认:读 `app.getLocale()`,非 zh/en 则 zh-CN
### 9.8 Wave 5 验收
- [ ] IT-SYNC-01WebDAV mock server 或 local-folder
- [ ] UT-LOG-01
- [ ] IT-UPDATE-01mock feed
---
## 10. IPC 通道(新增汇总)
```typescript
// pack.*
PACK_EXPORT_BOOK / PACK_IMPORT_BOOK / PACK_EXPORT_ALL / PACK_IMPORT_ALL / PACK_PROGRESS
// export matrix
EXPORT_CHAPTER_TXT / EXPORT_VOLUME / EXPORT_BOOK / EXPORT_PDF / EXPORT_DOCX
// read mode
READ_GET_VOLUME_CONTENT
// timeline
TIMELINE_LIST / TIMELINE_UPSERT / TIMELINE_DELETE / TIMELINE_CONFLICTS
// compliance
COMPLIANCE_CHECK_TEXT / COMPLIANCE_GET_WORDS / COMPLIANCE_SET_WORDS
// character arc
ARC_GET_FOR_CHARACTER
// style memory
STYLE_ANALYZE / STYLE_GET_PRESETS / STYLE_SAVE_PRESET
// undo
UNDO_PUSH / UNDO_LIST / UNDO_APPLY
// sync
SYNC_CONFIGURE / SYNC_RUN / SYNC_STATUS / SYNC_PROGRESS
// update
UPDATE_CHECK / UPDATE_DOWNLOAD / UPDATE_INSTALL
// attachments
ATTACHMENT_UPLOAD / ATTACHMENT_LIST / ATTACHMENT_DELETE
```
所有通道使用现有 `wrap()` + `IpcResult<T>` 信封。
---
## 11. 测试策略
| 波次 | 新增测试约计 |
|------|-------------|
| W1 | 8 E2E + 6 UT |
| W2 | 4 IT + 3 E2E |
| W3 | 6 E2E + 4 UT |
| W4 | 10 E2E + 8 UT |
| W5 | 4 IT + 3 E2E + 4 UT |
**原则**
- 每波合并前 `npm run build && npm run test && npm run test:e2e`(该波新增 spec
- Puppeteer/Updater/WebDAV 测试使用 mock 或 `vitest` 桩,不依赖外网
- 延续 `BILIN_E2E` / `BILIN_E2E_USER_DATA` 模式
---
## 12. 版本与文档
| 波次 | 版本 | CHANGELOG 要点 |
|------|------|----------------|
| W1 | v1.2.0 | 书籍设置、删改、模板变量、会话管理、专注/TTS |
| W2 | v1.3.0 | .novel / .novel-all |
| W3 | v1.4.0 | 导出矩阵、连续阅读 |
| W4 | v1.5.0 | 时间线、弧线、合规、AI 文风 |
| W5 | v2.0.0 | 同步、日志、更新、扩展 API |
每波更新:`package.json``README.md`、Settings 关于页、`design/readme.md` 实施状态表(可选)。
---
## 13. 实施计划文档
Master Program 拆为 5 份 implementation planbrainstorming 下一阶段):
1. `docs/superpowers/plans/2026-07-08-bilin-w1-foundation.md`
2. `docs/superpowers/plans/2026-07-08-bilin-w2-project-pack.md`
3. `docs/superpowers/plans/2026-07-08-bilin-w3-export-read.md`
4. `docs/superpowers/plans/2026-07-08-bilin-w4-creative-depth.md`
5. `docs/superpowers/plans/2026-07-08-bilin-w5-platform.md`
---
## 14. Spec 自检(2026-07-08
| 检查项 | 结果 |
|--------|------|
| TBD/占位段 | 无 |
| 内部矛盾 | Wave 依赖与 v1.1 推迟项已对齐 |
| 范围 | 大但已拆 5 波;单 plan 可执行 |
| 歧义 | PDF 用 PuppeteerSQLCipher 明确排除;插件不执行第三方代码 |
| YAGNI | 平台投稿 API、SQLCipher、远端崩溃上传已排除 |
@@ -0,0 +1,444 @@
# 笔临 P7 角色关系图谱 + P9 个人写作分析 设计规格
**版本**1.0
**日期**2026-07-08
**范围**:§7 角色关系图谱 MVP + §9 个人写作分析 MVP(含 `writing_sessions` 精确采集)
**前置**P5.3 + P6.1 已交付(v0.9.0`d4122c8`);P5.2 写作日志 / 热力图 / 番茄(v0.8.0)
**参考**`design/readme.md` v1.1 §7 / §9、`design/ui_pc.html` `#graphModal` / `#dashboardModal`
**目标版本**v1.0.0
---
## 1. 背景与目标
### 1.1 阶段定位
v0.9.0 已完成作家工作流核心链路(驾驶舱、日志、番茄、知识库、AI 上下文、注入历史)。`design/readme.md` §7 **角色关系图谱** 与 §9 **个人写作分析** 仍为概念/原型状态(`#graphModal` 为静态 demo,分析卡片无真实数据源)。
本阶段(v1.0.0)统一 MVP 交付:
- **P7(§7**:基于 `settings` 角色设定 + `properties.relationships` 的可视化关系图谱
- **P9(§9**:基于 `writing_sessions` + 现有 `writing_logs` / `pomodoro_daily` 的驾驶舱分析区
### 1.2 已确认决策(Brainstorming 2026-07-08
| 维度 | 决策 |
|------|------|
| 交付策略 | **方案 3**C + D 统一 MVP,单版 v1.0.0 |
| 分析数据采集 | **方案 A**:新增 `writing_sessions` 表,编辑活跃时精确记录 |
| 关系存储 | **`settings.properties.relationships[]` JSON**,不新增每书关系表 |
| 每书 schema | **保持 v8**,不迁移;`chapters.pov_character_id` 已有,补 UI |
| 全局 sqlite | **扩展 `writing_logs.sqlite`**(与 `writing_logs` / `pomodoro_daily` 同库) |
| 图谱库 | **Cytoscape.js + cose-bilkent**(渲染进程) |
| Session 合并 | 同一书籍连续写作,间隔 **≤ 5 分钟** 合并为同一 session |
| 图谱上限 | **100 角色节点**;超出 toast 提示,仍可按「仅有关系的节点」筛选展示 |
| 边去重 | 单向存储;渲染时 A↔B 去重,取较高 `intimacy` |
| 分析入口 | **仅驾驶舱内嵌卡片**,不做独立全屏分析页 |
| POV | 章节元数据下拉绑定 `pov_character_id` → 分析 POV 字数分布 |
### 1.3 验收标准
用户能完成:
1. 在设定面板为角色添加/编辑/删除关系(目标角色、描述、亲密度 1–5),保存后在关系图谱中可见对应边
2. 打开 **角色关系图谱** 全屏模态,看到力导向布局下的角色节点与关系边;可切换 grid / circle 布局
3. 点击节点 → 侧栏显示角色名与「编辑设定」跳转;点击边 → 侧栏编辑关系描述与亲密度
4. 在章节编辑区设置 **POV 角色**,驾驶舱 POV 分布反映该角色章节字数
5. 在章节编辑器写作并保存后,驾驶舱 **今日写作速度**(字/分钟)> 0;**24 小时时段热力**有数据
6. 驾驶舱展示:7 日字数趋势、番茄今日次数与均字/次、按卷字数占比(与现有字数聚合一致)
### 1.4 本规格不实现(明确排除)
| 项 | 归属 |
|----|------|
| AI 章节分析后自动关系建议(§7.2-3) | v1.1+ |
| 图谱中右键拖拽连线创建关系 | v1.1+ |
| 知识抽取面板「关系建议」一键添加 | v1.1+ |
| 预计完本日期 | §9 后续 |
| 角色弧线简报三节时间线(§20.3) | v1.1+ |
| 独立全屏「数据分析」页、书籍设置分析 Tab | 后续 |
| 导入导出、章节模板(§10 / §11) | 其他阶段 |
| 故事时间线模态(§5.7) | 其他阶段 |
| 设定条目(非 character)进入关系图谱 | 排除 |
---
## 2. 架构
### 2.1 进程模型
```
┌─────────────────────────────────────────────────────────────────────┐
│ 主进程(userData writing_logs.sqlite 扩展) │
│ · WritingSessionRepository / WritingSessionService │
│ · WritingAnalyticsService(聚合 sessions + logs + pomodoro + 章节) │
│ · ChapterWritingTracker.recordDelta → session.recordActivity │
└──────────────────────────────┬──────────────────────────────────────┘
┌──────────────────────────────▼──────────────────────────────────────┐
│ 主进程(每书 .sqliteschema v8
│ · CharacterGraphService(读 settings,写 properties.relationships
│ · SettingRepository(已有) │
│ · ChapterRepository.pov_character_id(已有) │
└──────────────────────────────┬──────────────────────────────────────┘
│ IPC
┌──────────────────────────────▼──────────────────────────────────────┐
│ 渲染进程 │
│ · CharacterGraphModalCytoscape + 侧栏) │
│ · SettingRelationshipsEditor(角色设定页关系 CRUD
│ · CockpitModal 扩展 analytics 区块 │
│ · ChapterHeader / ChapterListPOV 下拉 │
└─────────────────────────────────────────────────────────────────────┘
```
### 2.2 模块边界
| 模块 | 职责 | 依赖 |
|------|------|------|
| `WritingSessionService` | 活跃 session 状态机;5 分钟 idle 切分;`recordActivity` | `WritingSessionRepository` |
| `WritingAnalyticsService` | `getSummary(bookId)` 聚合分析指标 | sessions, logs, pomodoro, chapters, settings |
| `CharacterGraphService` | 构建 nodes/edgesCRUD 关系;边去重 | `SettingRepository` |
| `ChapterWritingTracker` | 已有字数 delta → **扩展**调用 session | `WritingLogService`, `WritingSessionService` |
| `CharacterGraphModal` | Cytoscape 渲染与布局切换 | preload `graph.*` |
| `CockpitModal` | 展示 `WritingAnalyticsSummary` | preload `analytics.getSummary` |
**原则**
- Session 写入与 `writing_logs` 共用 `recordDelta` 路径,**不重复计字**
- 图谱数据以 `settings` 为唯一真相源;Cytoscape 元素由 IPC 返回的 DTO 构建
- 分析服务只读;失败时驾驶舱分析区显示占位/空态,不阻断驾驶舱其他卡片
---
## 3. 数据模型
### 3.1 全局 userData:扩展 `writing_logs.sqlite`
`WritingLogRepository.migrate()` 追加:
```sql
CREATE TABLE IF NOT EXISTS writing_sessions (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL,
chapter_id TEXT,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
word_delta INTEGER NOT NULL DEFAULT 0,
date TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_book_date
ON writing_sessions (book_id, date);
CREATE INDEX IF NOT EXISTS idx_sessions_book_started
ON writing_sessions (book_id, started_at);
```
**Session 状态机**`WritingSessionService`):
| 事件 | 行为 |
|------|------|
| `recordActivity(bookId, chapterId, delta)` | 若无活跃 session 或距上次 `ended_at` > 5min → `INSERT` 新 session;否则 `UPDATE ended_at, word_delta += delta` |
| `endActive(bookId)` | 书籍切换 / 应用退出前结束活跃 session |
| `delta === 0` | 忽略(不创建空 session |
活跃 session 可在主进程内存缓存 `{ bookId, sessionId, lastEndedAt }`DB 为持久真相。
### 3.2 角色关系 JSON(每书 `settings.properties`
```typescript
interface CharacterRelationship {
id: string
targetId: string
label: string
intimacy: 1 | 2 | 3 | 4 | 5
}
// SettingEntry.properties.relationships?: CharacterRelationship[]
// 仅 type === 'character' 时读写
```
**边去重规则**(渲染):对无序对 `{A,B}` 仅输出一条边;若 A→B 与 B→A 均存在,取 `max(intimacy)``label` 优先取较高亲密度源的 label。
**CRUD**
- `upsertRelationship(sourceId, { id?, targetId, label, intimacy })` — 更新源角色 `properties.relationships`
- `deleteRelationship(sourceId, relationshipId)`
- 禁止 `targetId === sourceId`;禁止指向非 character 设定
### 3.3 POV(已有)
- DB 列:`chapters.pov_character_id`schema v8 已有 `tryAlter`
- UI:章节编辑器工具栏或章节列表元数据区,`select` 绑定 `type='character'` 设定
- IPC:复用 `chapter.update` patch `{ povCharacterId }`
### 3.4 类型扩展(`src/shared/types.ts`
```typescript
export interface CharacterGraphNode {
id: string
label: string
}
export interface CharacterGraphEdge {
id: string
source: string
target: string
label: string
intimacy: 1 | 2 | 3 | 4 | 5
}
export interface CharacterGraphData {
nodes: CharacterGraphNode[]
edges: CharacterGraphEdge[]
truncated: boolean
nodeCount: number
}
export interface WritingAnalyticsSummary {
todayWords: number
todaySpeedWpm: number | null
weekTrend: { date: string; words: number }[]
hourlyHeatmap: number[] // length 24
pomodoroToday: number
pomodoroAvgWords: number | null
povDistribution: { characterId: string; name: string; words: number }[]
volumeDistribution: { volumeId: string; title: string; words: number }[]
}
```
---
## 4. 服务层设计
### 4.1 WritingAnalyticsService
```typescript
getSummary(bookId: string): WritingAnalyticsSummary
```
| 字段 | 计算方式 |
|------|----------|
| `todayWords` | `WritingLogService.getStats(bookId).todayWords` |
| `todaySpeedWpm` | 今日 sessions`sum(word_delta) / sum(minutes(ended_at - started_at))`;无 session 或零时长 → `null` |
| `weekTrend` | 近 7 天 `writing_logs` 按 date |
| `hourlyHeatmap` | 近 30 天 sessions,按 `started_at` 小时 bucket 累加 `word_delta`24 槽) |
| `pomodoroToday` | `PomodoroDailyRepository.getTodayCount` |
| `pomodoroAvgWords` | 今日 `total_words / completed_count`count=0 → `null` |
| `povDistribution` | chapters GROUP BY `pov_character_id` SUM `word_count`JOIN character name |
| `volumeDistribution` | chapters GROUP BY `volume_id` SUM `word_count` |
未设置 POV 的章节计入 `{ characterId: '__none__', name: i18n未指定 }` 或单独过滤(实现时选 **单独「未指定」桶**)。
### 4.2 CharacterGraphService
```typescript
getData(bookId: string, filter?: 'all' | 'connected'): CharacterGraphData
upsertRelationship(bookId, sourceId, input): CharacterRelationship
deleteRelationship(bookId, sourceId, relationshipId): void
```
- `filter: 'connected'`:仅包含有至少一条边的节点(用于超 100 节点时的降级展示)
- `truncated: true` 当 character 总数 > 100
### 4.3 ChapterWritingTracker 扩展
```typescript
recordDelta(chapterId: string, newWordCount: number): void {
// 现有 writing_logs delta 逻辑不变
if (delta !== 0) {
this.writingLogs.addToday(this.bookId, delta)
this.sessions.recordActivity(this.bookId, chapterId, delta)
}
// snapshot upsert 不变
}
```
---
## 5. IPC 与 preload
```typescript
// ipc-channels.ts
GRAPH_GET_DATA: 'graph:getData',
GRAPH_UPSERT_RELATIONSHIP: 'graph:upsertRelationship',
GRAPH_DELETE_RELATIONSHIP: 'graph:deleteRelationship',
ANALYTICS_SUMMARY: 'analytics:summary',
```
| 通道 | 参数 | 返回 |
|------|------|------|
| `graph:getData` | `{ bookId, filter? }` | `IpcResult<CharacterGraphData>` |
| `graph:upsertRelationship` | `{ bookId, sourceId, relationship }` | `IpcResult<CharacterRelationship>` |
| `graph:deleteRelationship` | `{ bookId, sourceId, relationshipId }` | `IpcResult<void>` |
| `analytics:summary` | `{ bookId }` | `IpcResult<WritingAnalyticsSummary>` |
`cockpit:getSummary` **不合并** analytics(保持单一职责);驾驶舱 UI 并行请求 `cockpit.getSummary` + `analytics.getSummary`,或在 renderer 层 `Promise.all`
---
## 6. UI 规格
### 6.1 角色关系图谱(`CharacterGraphModal`
参考 `design/ui_pc.html` `#graphModal`
| 元素 | testid | 说明 |
|------|--------|------|
| 模态 | `character-graph-modal` | 宽屏 `modal-content--wide` |
| Cytoscape 容器 | `character-graph-cy` | 最小高度 480px |
| 布局按钮 | `graph-layout-cose` / `grid` / `circle` | |
| 侧栏 | `character-graph-sidebar` | 节点/边详情 |
| 设定入口按钮 | `setting-open-graph` | 设定面板工具栏 |
**亲密度配色**(边 `line-color`):
| intimacy | 颜色 |
|----------|------|
| 1 | `#4ea8de` 蓝 |
| 2 | `#45b7aa` 青 |
| 3 | `#3fb950` 绿 |
| 4 | `#f0883e` 橙 |
| 5 | `#e0556a` 红 |
**Cytoscape 依赖**(渲染进程):`cytoscape``cytoscape-cose-bilkent`destroy 于 modal 关闭。
### 6.2 设定面板关系编辑(`SettingRelationshipsEditor`
- 嵌入角色设定编辑区(TipTap 下方或侧栏,当 `type === 'character'`
- 列表:目标角色名、label、亲密度、删除
- 添加:目标角色下拉(排除自身)、label 输入、intimacy range 15
- testid`setting-relationships``setting-relationship-add`
### 6.3 驾驶舱分析区(`CockpitModal` 扩展)
| 卡片 | testid | 内容 |
|------|--------|------|
| 分析区容器 | `cockpit-analytics` | |
| 今日速度 | `cockpit-speed-today` | `{{wpm}} 字/分钟``—` |
| 时段热力 | `cockpit-hourly-heatmap` | 24 格横条 |
| POV 分布 | `cockpit-pov-chart` | Top 5 横向条 |
| 按卷占比 | `cockpit-volume-chart` | 卷名字 + 字数 |
| 7 日趋势 | `cockpit-week-trend` | 7 柱 mini chart |
驾驶舱底部增加「打开关系图谱」按钮:`cockpit-open-graph`
### 6.4 章节 POV
- 位置:章节编辑器标题栏或 `#editor-statusbar` 附近
- testid`chapter-pov-select`
- 选项:空(未指定)+ 所有 character 设定
- 变更时 debounce 调用 `chapter.update`
---
## 7. i18n 键(节选)
```json
"graph.title": "角色关系图谱",
"graph.layout.cose": "力导向",
"graph.layout.grid": "网格",
"graph.layout.circle": "圆形",
"graph.truncated": "角色过多,仅显示前 {{count}} 个",
"graph.sidebar.node": "角色:{{name}}",
"graph.sidebar.edge": "关系:{{label}}",
"graph.editSetting": "编辑设定",
"relationship.title": "角色关系",
"relationship.add": "添加关系",
"relationship.target": "目标角色",
"relationship.label": "关系描述",
"relationship.intimacy": "亲密度",
"analytics.title": "写作分析",
"analytics.speedToday": "今日 {{wpm}} 字/分钟",
"analytics.speedUnknown": "暂无速度数据",
"analytics.hourlyHeatmap": "写作时段(近30天)",
"analytics.povDistribution": "视角字数分布",
"analytics.volumeDistribution": "按卷字数",
"analytics.weekTrend": "近 7 日字数",
"analytics.povUnassigned": "未指定视角",
"chapter.pov": "视角角色",
"cockpit.openGraph": "角色关系图谱"
```
---
## 8. 测试要求
### 8.1 单元 / 集成
| 用例ID | 层级 | 场景 | 预期 |
|--------|------|------|------|
| UT-SESSION-01 | 单元 | 5min 内两次 recordActivity | 同一 sessionword_delta 累加 |
| UT-SESSION-02 | 单元 | 间隔 6min 两次 recordActivity | 两个 session |
| UT-SESSION-03 | 单元 | delta=0 | 不创建 session |
| UT-GRAPH-01 | 单元 | 3 角色 2 关系 getData | nodes=3, edges=2 |
| UT-GRAPH-02 | 单元 | A→B 与 B→A 双向存储 | 渲染 1 条边,intimacy 取 max |
| UT-GRAPH-03 | 单元 | 101 个 character | truncated=true |
| UT-ANALYTICS-01 | 单元 | 2 个 session 共 100 字 / 10 分钟 | todaySpeedWpm=10 |
| UT-ANALYTICS-02 | 单元 | hourlyHeatmap | 24 长度,对应小时有值 |
| IT-ANALYTICS-01 | 集成 | chapter update → recordDelta → getSummary | todaySpeedWpm > 0 |
| IT-GRAPH-01 | 集成 | upsertRelationship → getData | 边可见 |
### 8.2 E2E
| 用例ID | 场景 | 预期 |
|--------|------|------|
| E2E-GRAPH-01 | 打开关系图谱 | 力导向图可见节点 |
| E2E-GRAPH-02 | 点击节点 | 侧栏显示角色名 |
| E2E-GRAPH-03 | 切换圆形布局 | 布局变化 |
| E2E-GRAPH-04 | 设定面板添加关系 | 图谱出现新边 |
| E2E-ANALYTICS-01 | 写作保存后打开驾驶舱 | `cockpit-speed-today` 有数值 |
| E2E-ANALYTICS-02 | 设置 POV 后刷新驾驶舱 | POV chart 含该角色 |
**E2E 图谱**:不依赖 AI / LM Studio。
---
## 9. 文件清单(实现阶段参考)
| 路径 | 说明 |
|------|------|
| `src/main/db/repositories/writing-session.repo.ts` | sessions CRUD |
| `src/main/services/writing-session.service.ts` | 状态机 |
| `src/main/services/writing-analytics.service.ts` | 分析聚合 |
| `src/main/services/character-graph.service.ts` | 图谱 DTO + 关系 CRUD |
| `src/main/ipc/handlers/graph.handler.ts` | 图谱 IPC |
| `src/main/ipc/handlers/analytics.handler.ts` | 分析 IPC |
| `src/renderer/components/graph/CharacterGraphModal.tsx` | Cytoscape UI |
| `src/renderer/components/setting/SettingRelationshipsEditor.tsx` | 关系 CRUD |
| `src/renderer/components/cockpit/CockpitAnalytics.tsx` | 分析卡片 |
| `src/renderer/components/cockpit/HourlyHeatmap.tsx` | 24 槽热力 |
| `tests/main/writing-session.test.ts` | UT-SESSION-* |
| `tests/main/character-graph.test.ts` | UT-GRAPH-* |
| `tests/main/writing-analytics.test.ts` | UT-ANALYTICS-* |
| `e2e/character-graph.spec.ts` | E2E-GRAPH-* |
| `e2e/writing-analytics.spec.ts` | E2E-ANALYTICS-* |
---
## 10. Definition of Done
- [ ] `writing_sessions` 表与 Session 状态机落地
- [ ] 角色关系 CRUD + Cytoscape 图谱模态
- [ ] 驾驶舱分析区(速度 / 时段 / POV / 卷 / 7 日)
- [ ] 章节 POV 选择 UI
- [ ] UT / IT / E2E 用例通过
- [ ] i18n zh-CN / en 键齐全
- [ ] `npm run build` + `npm run test` 全绿
- [ ] 版本号 **1.0.0**`package.json` / README / Settings 关于页)
---
## 11. Spec Coveragedesign/readme.md
| 设计章节 | 本 spec 覆盖 |
|----------|-------------|
| §7.1 力导向图、节点/边、布局 | ✅ MVP |
| §7.2-1 设定面板手动添加 | ✅ |
| §7.2-2 图谱拖拽连线 | ❌ v1.1 |
| §7.2-3 AI 关系建议 | ❌ v1.1 |
| §7.3 E2E-GRAPH-01~04 | ✅(04 改为设定面板添加) |
| §9 写作速度仪表盘 | ✅ 今日速度 + 时段热力 |
| §9 习惯追踪 | 部分(连更/番茄已有;session 时长留待后续展示) |
| §9 POV 字数分布 | ✅ |
| §9 角色弧线简报 | ❌ v1.1 |
| §20.3 角色弧线 | ❌ v1.1 |
@@ -0,0 +1,399 @@
# 笔临 v1.1 作家日更工具包 设计规格
**版本**1.0
**日期**2026-07-08
**范围**:§10 章节模板 + §11.1 书籍导入(txt/md/docx+ §20.2 投稿导出预设 + §20.6 全局灵感捕获增强
**前置**v1.0.0 已交付(`4adafa1`:P7 角色关系图谱 + P9 写作分析)
**参考**`design/readme.md` v1.1 §10 / §11.1 / §20.2 / §20.6、`design/ui_pc.html` `#templateModal` / `#importModal` / `#exportModal` / `#inspirationModal`
**目标版本**v1.1.0
---
## 1. 背景与目标
### 1.1 阶段定位
v1.0.0 已完成「看数据」能力(关系图谱、写作分析、POV)。`design/readme.md` §20.7 审查清单仍有多项 **有 UI 原型、无真实实现** 的作家刚需:
- 主页「导入书籍」为 `comingSoon`
- 章节模板 / 快速新建章仅有 `#templateModal` 静态原型
- 导出模态「复制到剪贴板(网文投稿格式)」无后端
- 灵感捕获 `Ctrl+Shift+I` 仅编辑器内可用,保存后会跳转侧栏打断心流
本阶段(v1.1.0)交付 **作家日更工具包**,形成「写得更快 → 发得更顺 → 灵感不漏」闭环。
### 1.2 已确认决策(Brainstorming 2026-07-08
| 维度 | 决策 |
|------|------|
| 方向 | **方案 B**:作家日更工具包 |
| MVP 边界 | **方案 A(完整四模块)**:模板 + txt/md/docx 导入 + 投稿预设 + 灵感增强 |
| 模板存储 | `GlobalSettings.chapterTemplates[]`;内置模板代码常量合并展示 |
| 模板解析 | **渲染进程** `chapter-template.ts` 替换变量后 `chapter.create` + `chapter.update` |
| docx 解析 | **mammoth**docx → HTML,保留 bold/italic |
| md 解析 | **marked**md → HTML |
| 导入分章 | 自动正则 / 双换行 / 自定义正则 三档 |
| 大文件 | >20MB 导入前二次确认;进度条 IPC 事件 |
| 投稿导出 | **单章**复制剪贴板 + 另存 `.txt`;内置「通用网文」预设 |
| 投稿预设存储 | `GlobalSettings.submissionPresets[]` |
| 灵感增强 | 快捷键全局可用;未开书时模态内选书;保存后仅 toast、不跳转 |
| 置顶 | 灵感模态最高 z-index;不新建独立 BrowserWindow |
### 1.3 验收标准
用户能完成:
1. 在章节列表点击「快速新建」,选择模板并输入标题,创建章节且编辑器自动填入模板正文(含变量替换)
2. 在设置页创建/编辑/删除自定义章节模板;内置模板只读展示
3. 主页「导入书籍」选择 `.txt` / `.md` / `.docx`,预览分章结果,确认后创建新书并导入全部章节
4. 导入 >20MB 文件前看到确认提示;导入过程显示进度
5. 编辑器内 `Ctrl+Shift+E` 打开导出模态,选择「通用网文」预设,**复制到剪贴板** 得到段首空两格 + `# 第N章 标题` 格式纯文本
6. 设置页自定义投稿预设(标题格式、段首缩进、段落间距),导出时应用
7. 在主页按 `Ctrl+Shift+I` 唤起灵感捕获,选择书籍后保存,仅 toast 确认、焦点不打断原界面
8. 编辑器内灵感捕获行为与 7 一致(不再自动跳转灵感面板)
### 1.4 本规格不实现(明确排除)
| 项 | 归属 |
|----|------|
| `.novel` / `.novel-all` 项目包导入导出 | v1.2+ |
| PDF / Word 全书导出、分卷批量投稿复制 | v1.2+ |
| §11.2 完整导出矩阵(docx 目录、PDF 排版) | v1.2+ |
| §20.5 敏感词合规、预设内敏感词替换规则 | v1.2+ |
| §20.3 角色弧线简报 | v1.2+(图谱深化线) |
| §5.7 故事时间线、§5.8 连续阅读模式 | v1.2+ |
| 导入 `.novel` 包 | v1.2+ |
| 平台 API 直连投稿 | 永久 YAGNI |
| 模板变量 `{outlineItem}` 自动关联当前章大纲 | v1.1 留空字符串(无关联时);手动关联后续 |
---
## 2. 架构
### 2.1 进程模型
```
┌─────────────────────────────────────────────────────────────────────┐
│ 渲染进程 │
│ · TemplateQuickCreateModal / TemplateSettingsTab │
│ · ImportBookModal │
│ · ExportModal + SubmissionPresetSettingsTab │
│ · InspirationModal(增强:全局唤起、选书、无跳转) │
│ · chapter-template.ts / submission-formatter.ts(纯函数) │
└──────────────────────────────┬──────────────────────────────────────┘
│ IPC
┌────────────────────────────▼────────────────────────────────────────┐
│ 主进程 │
│ · ImportService(读文件、分章、创建书+卷+章批量写入) │
│ · ExportService(HTML→纯文本、应用投稿预设、写剪贴板/文件) │
│ · BookRegistryService(已有 create/open
│ · GlobalSettingsService(扩展 chapterTemplates / submissionPresets)│
│ · dialog.showOpenDialog / clipboard │
└─────────────────────────────────────────────────────────────────────┘
```
### 2.2 模块边界
| 模块 | 职责 | 依赖 |
|------|------|------|
| `chapter-template.ts` | 变量替换、章节号计算 | `GlobalSettings`, 书籍 volumes/chapters/settings |
| `ImportService` | 解析文件、分章、创建书 | `BookRegistry`, `ChapterRepository`, mammoth, marked |
| `ExportService` | 单章格式化、剪贴板、存盘 | `ChapterRepository`, `GlobalSettings` |
| `SubmissionFormatter` | HTML 剥离、段首缩进、标题行 | 纯函数,主/渲染均可调用 |
| `TemplateQuickCreateModal` | 选模板 → IPC 创建章 | `chapter.create/update` |
| `ImportBookModal` | 选文件 → preview → execute | `import.*` |
| `ExportModal` | 选预设 → 复制/存盘 | `export.*` |
**原则**
- 导入/导出重 IO 与解析放主进程;模板变量替换可在渲染进程(减少 IPC 往返)
- 导入失败整事务回滚:书未创建或创建后写章失败则 `registry.delete(bookId)`
- 剪贴板写入仅主进程(Electron `clipboard` API
---
## 3. 数据模型
### 3.1 GlobalSettings 扩展
```typescript
export interface ChapterTemplate {
id: string
name: string
body: string
defaultStatus?: ChapterStatus
builtin?: boolean
}
export interface SubmissionPreset {
id: string
name: string
titleFormat: string // 默认 "# 第{chapterNumber}章 {chapterTitle}"
indentParagraphs: boolean // 段首两全角空格
includeAuthorsNote: boolean // 保留「作者的话」段(检测 —— 分隔)
paragraphGap: 'single' | 'double'
}
// GlobalSettings 追加:
chapterTemplates?: ChapterTemplate[]
submissionPresets?: SubmissionPreset[]
defaultSubmissionPresetId?: string
```
**内置模板**`src/shared/builtin-templates.ts`,不写入 JSON):
| id | name | body 摘要 |
|----|------|-----------|
| `builtin-generic` | 通用模板 | `## 第{chapterNumber}章 {chapterTitle}\n\n` |
| `builtin-webnovel` | 网文常用 | 正文 + `\n\n——\n作者的话:\n` |
**内置投稿预设**
| id | name | 规则 |
|----|------|------|
| `builtin-webnovel` | 通用网文 | `titleFormat: "# 第{chapterNumber}章 {chapterTitle}"`, `indentParagraphs: true`, `paragraphGap: 'double'` |
`GlobalSettingsService` 读取时合并内置项;用户不可删除内置项。
### 3.2 模板变量
| 变量 | 来源 |
|------|------|
| `{chapterNumber}` | 目标卷 `chapters.length + 1` |
| `{chapterTitle}` | 用户输入 |
| `{penName}` | `settings.penName` |
| `{date}` | `localDateString()` |
| `{volumeName}` | 当前卷 `name` |
| `{previousChapterTitle}` | 同卷上一章 `title`,首章 `""` |
| `{outlineItem}` | v1.1 固定 `""`(章节创建时无 outline 关联) |
| `{characterList}` | 当前卷内 `settings.type=character``chapterIds` 与卷内任一章有交集的 `name`,逗号连接 |
### 3.3 导入分章
```typescript
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
}
```
**自动分章正则**(依次尝试,命中即用):
1. `/^#\s*第.+章[^\n]*/gm`Markdown 标题)
2. `/^第[一二三四五六七八九十百千零〇\d]+章[^\n]*/gm`(纯文本章标题行)
**paragraph 模式**:按 `\n\s*\n` 分段;标题取段首非空行,截断 50 字。
**首章前导内容**:合并入第一章 `warnings` 提示,不丢弃。
---
## 4. IPC
### 4.1 通道
```typescript
IMPORT_PICK_FILE: 'import:pickFile'
IMPORT_PREVIEW: 'import:preview'
IMPORT_EXECUTE: 'import:execute'
IMPORT_PROGRESS: 'import:progress' // main → renderer event
EXPORT_FORMAT_CHAPTER: 'export:formatChapter'
EXPORT_COPY_CLIPBOARD: 'export:copyClipboard'
EXPORT_SAVE_TXT: 'export:saveTxt'
```
### 4.2 载荷
```typescript
// import:pickFile → { canceled, filePath? }
// import:preview → { filePath, splitMode, customRegex? } → ImportPreviewResult
// import:execute → ImportExecuteParams → { bookId }
// import:progress → { phase: 'parsing'|'writing', current, total }
// export:formatChapter → { bookId, chapterId, presetId } → { text }
// export:copyClipboard → { text } → void
// export:saveTxt → { text, defaultName } → { savedPath? }
```
---
## 5. UI 规格
### 5.1 TemplateQuickCreateModal
- 入口:章节列表底部 `data-testid="chapter-quick-new"`
- 布局:参考 `#templateModal` 卡片网格 + 标题输入
- 流程:选模板 → 输入标题 → 创建 → `switchTarget` 到新章
### 5.2 设置页 — 章节模板 Tab
- `data-testid="settings-chapter-templates"`
- 列表:内置 + 自定义;自定义可编辑 body(textarea)、删除
- 新建:`setting-template-add`
### 5.3 ImportBookModal
- 入口:主页 `home.importBook`(替换 comingSoon
- `data-testid="import-book-modal"`
- 字段:文件路径(浏览)、书名、分类、分章规则下拉、自定义正则输入(regex 模式显示)
- 预览区:章数 + 前 5 章标题列表
- >20MB`import-large-file-confirm` 对话框
- 进度:`import-progress-bar`
### 5.4 ExportModal
- 入口:编辑器工具栏 `data-testid="export-chapter"` + 快捷键 `exportBook`
- `data-testid="export-modal"`
- 折叠区「投稿预设」:`export-preset-select`
- 操作:`export-copy-clipboard` / `export-save-txt`
- 预览:格式化后前 500 字纯文本
### 5.5 设置页 — 投稿预设 Tab
- `data-testid="settings-submission-presets"`
- 字段:名称、标题格式、段首缩进 checkbox、段落间距 select
### 5.6 InspirationModal 增强
- `App.tsx``captureInspiration` 移除 `view !== 'editor'` 限制
- 未开书:显示 `inspiration-book-select`(书籍列表下拉,默认 `lastOpenedAt` 最新)
- 保存后:删除 `switchTarget` + `setSidebarPanel('inspiration')`;仅 `showToast` + `setOpen(false)`
- 样式:`inspiration-modal--capture` 提升 z-index(高于 TopBar
---
## 6. 依赖
| 包 | 用途 |
|----|------|
| `mammoth` | docx → HTML |
| `marked` | md → HTML |
均在主进程 `ImportService` 使用;electron-vite 打包时 external 或 bundle 按现有惯例。
---
## 7. 测试
| 用例 ID | 层级 | 场景 | 预期 |
|---------|------|------|------|
| UT-TEMPL-01 | 单元 | 解析含 `{chapterNumber}` `{chapterTitle}` | 正确替换 |
| UT-TEMPL-02 | 单元 | `{volumeName}` 替换 | 卷名正确 |
| UT-TEMPL-03 | 单元 | `{characterList}` 卷内角色 | 逗号列表 |
| IT-TEMPL-01 | 集成 | 模板快速新建写入 DB | content 含替换后正文 |
| UT-IMPORT-01 | 单元 | txt 自动分章 3 章 | `chapters.length === 3` |
| UT-IMPORT-02 | 单元 | md 转 HTML | 含 `<p>` |
| UT-IMPORT-03 | 单元 | docx mammoth | 非空 HTML |
| IT-IMPORT-01 | 集成 | execute 创建书+章 | `registry.open` 章数匹配 |
| UT-EXPORT-01 | 单元 | 段首空两格 | 每段 `  ` 前缀 |
| UT-EXPORT-02 | 单元 | 标题格式 | `# 第1章 标题` |
| IT-EXPORT-01 | 集成 | copyClipboard | 剪贴板非空 |
| E2E-TEMPL-01 | E2E | 快速新建章 | 编辑器有模板内容 |
| E2E-IMPORT-01 | E2E | 导入 txt | 新书可见多章 |
| E2E-EXPORT-01 | E2E | 复制投稿格式 | 剪贴板含章标题 |
| E2E-INSP-01 | E2E | 主页 Ctrl+Shift+I 保存 | toast,不跳编辑器 |
---
## 8. i18n 键(节选)
```json
"template.title": "章节模板",
"template.quickNew": "模板新建章",
"template.builtin": "内置",
"template.custom": "自定义",
"import.title": "导入书籍",
"import.pickFile": "浏览文件",
"import.splitAuto": "自动识别章节",
"import.splitParagraph": "双换行分段",
"import.splitRegex": "自定义正则",
"import.largeFileConfirm": "文件较大({{size}}),导入可能需要 30 秒至 2 分钟,是否继续?",
"import.progress": "正在导入 {{current}}/{{total}}",
"export.title": "导出章节",
"export.submissionPresets": "投稿预设",
"export.copyClipboard": "复制到剪贴板",
"export.saveTxt": "另存为文本",
"export.copied": "已复制到剪贴板",
"submission.title": "投稿预设",
"submission.titleFormat": "标题格式",
"submission.indent": "段首空两格",
"inspiration.selectBook": "关联书籍",
"inspiration.saved": "灵感已保存"
```
---
## 9. 文件清单
| 文件 | 职责 |
|------|------|
| `src/shared/types.ts` | `ChapterTemplate`, `SubmissionPreset`, Import/Export DTO |
| `src/shared/builtin-templates.ts` | 内置模板与投稿预设 |
| `src/shared/ipc-channels.ts` | `import.*` / `export.*` |
| `src/main/services/import.service.ts` | 解析 + 分章 + 创建书 |
| `src/main/services/export.service.ts` | 格式化 + 剪贴板/存盘 |
| `src/main/lib/submission-formatter.ts` | HTML → 投稿纯文本 |
| `src/main/lib/chapter-splitter.ts` | 分章逻辑 |
| `src/main/ipc/handlers/import.handler.ts` | 导入 IPC |
| `src/main/ipc/handlers/export.handler.ts` | 导出 IPC |
| `src/renderer/lib/chapter-template.ts` | 模板变量替换 |
| `src/renderer/components/chapter/TemplateQuickCreateModal.tsx` | 快速新建 |
| `src/renderer/components/import/ImportBookModal.tsx` | 导入 UI |
| `src/renderer/components/export/ExportModal.tsx` | 导出 UI |
| `src/renderer/components/settings/TemplateSettingsTab.tsx` | 模板管理 |
| `src/renderer/components/settings/SubmissionPresetTab.tsx` | 投稿预设管理 |
| `src/renderer/components/inspiration/InspirationModal.tsx` | 增强 |
| `src/renderer/components/home/HomePage.tsx` | 接入导入 |
| `src/renderer/components/layout/EditorLayout.tsx` | 导出入口 |
| `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 |
---
## 10. 实现顺序建议
1. 类型 + 内置常量 + `chapter-template.ts` + 快速新建 UI
2. `SubmissionFormatter` + ExportService + ExportModal + 设置 Tab
3. InspirationModal 增强(独立、改动小)
4. ImportService + ImportBookModal(依赖 mammoth/marked,放最后)
**目标版本号**:实现完成后 `package.json` / README / 关于页 → **v1.1.0**
---
## 11. Spec 自检
| 检查项 | 状态 |
|--------|------|
| 无 TBD/TODO 占位 | ✅ |
| 与方案 A 四模块一致 | ✅ |
| `.novel` 明确排除 | ✅ |
| 单章投稿导出范围明确 | ✅ |
| IPC/数据模型无矛盾 | ✅ |
| 测试用例覆盖四模块 | ✅ |
+43
View File
@@ -0,0 +1,43 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import { launchApp, skipOnboarding, createBookAndOpenEditor, openBookSettings } from './helpers'
test.describe('Book settings', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-book-settings-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-BOOK-01: rename book, always cockpit, delete book', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '原始书名')
await openBookSettings(page)
await page.getByTestId('book-settings-name').fill('改后书名')
await page.getByTestId('book-settings-always-cockpit').check()
await page.getByTestId('book-settings-save').click()
await expect(page.locator('.sidebar-header')).toContainText('改后书名', { timeout: 10_000 })
page.once('dialog', (dialog) => dialog.accept())
await page.getByTestId('book-settings-delete').click()
await expect(page.locator('#home-page')).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('改后书名')).toHaveCount(0)
await app.close()
})
})
+55
View File
@@ -0,0 +1,55 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import { launchApp, skipOnboarding, createBookAndOpenEditor, ensureChapterEditor } from './helpers'
test.describe('Chapter management', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-chapter-mgmt-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-CHAP-01: double-click rename and delete chapter', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '章节管理书')
await ensureChapterEditor(page)
await page.getByTestId('sidebar-tab-chapters').click()
const firstChapter = page.locator('.chapter-item').first()
await expect(firstChapter).toBeVisible({ timeout: 10_000 })
const chapterTestId = await firstChapter.getAttribute('data-testid')
const chapterId = chapterTestId?.replace('chapter-item-', '') ?? ''
expect(chapterId).toBeTruthy()
await firstChapter.dblclick()
const renameInput = page.getByTestId(`chapter-rename-${chapterId}`)
await expect(renameInput).toBeVisible({ timeout: 5000 })
await renameInput.fill('重命名章节')
await renameInput.blur()
await expect(firstChapter).toContainText('重命名章节', { timeout: 10_000 })
await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.chapter-item')).toHaveCount(2, { timeout: 10_000 })
page.once('dialog', (dialog) => dialog.accept())
await firstChapter.hover()
await page.getByTestId(`chapter-delete-${chapterId}`).click()
await expect(page.locator('.chapter-item')).toHaveCount(1, { timeout: 10_000 })
await app.close()
})
})
+75
View File
@@ -0,0 +1,75 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import { launchApp, skipOnboarding, createBookAndOpenEditor, ensureChapterEditor, addCharacterSetting, dismissCockpitIfOpen } from './helpers'
test.describe('Chapter template', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-templ-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-TEMPL-01: quick create from template fills editor', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '模板测试书')
await page.getByTestId('chapter-quick-new').click()
await expect(page.getByTestId('template-quick-create-modal')).toBeVisible()
await page.getByTestId('template-chapter-title').fill('觉醒之路')
await page.getByTestId('template-create-btn').click()
await expect(page.getByTestId('template-quick-create-modal')).toBeHidden({ timeout: 10_000 })
const editor = page.locator('.ProseMirror')
await expect(editor).toBeVisible({ timeout: 10_000 })
await expect(editor).toContainText('觉醒之路', { timeout: 10_000 })
await expect(editor).toContainText('第')
await app.close()
})
test('E2E-TEMPL-05: characterList from settings in quick create', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '角色模板书')
await addCharacterSetting(page, '林远')
await addCharacterSetting(page, '苏晴')
await page.locator('#topbar .top-btn').filter({ hasText: '⚙' }).click()
await page.getByTestId('settings-nav-templates').click()
await page.getByTestId('setting-template-add').click()
const customCard = page.locator('.template-settings-item').filter({ hasText: '新模板' }).last()
await customCard.getByRole('button', { name: '编辑' }).click()
await page.locator('.template-edit-panel textarea').fill('出场角色:{characterList}\n\n正文')
await page.locator('#topbar .tab').filter({ hasText: '角色模板书' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 10_000 })
await dismissCockpitIfOpen(page)
await page.getByTestId('sidebar-tab-chapters').click()
await page.getByTestId('chapter-quick-new').click()
await expect(page.getByTestId('template-quick-create-modal')).toBeVisible()
await page.locator('.template-card').filter({ hasText: '新模板' }).click()
await page.getByTestId('template-chapter-title').fill('角色章')
await page.getByTestId('template-create-btn').click()
const editor = page.locator('.ProseMirror')
await expect(editor).toContainText('林远', { timeout: 10_000 })
await expect(editor).toContainText('苏晴')
await app.close()
})
})
+119
View File
@@ -0,0 +1,119 @@
import path from 'path'
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect, _electron as electron, type Page } from '@playwright/test'
const ROOT = path.join(import.meta.dirname, '..')
async function launchApp(userDataDir: string) {
return electron.launch({
args: [ROOT],
env: {
...process.env,
BILIN_E2E: '1',
BILIN_E2E_USER_DATA: userDataDir
}
})
}
async function skipOnboarding(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
await expect(page.getByText('欢迎使用笔临')).toBeVisible({ timeout: 15_000 })
await page.getByRole('button', { name: '跳过' }).click()
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
if (await cockpit.isVisible({ timeout: 3000 }).catch(() => false)) {
await page.locator('[data-testid="cockpit-modal"] .modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
}
}
async function createAndOpenBook(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
}
async function createCharacterSetting(page: Page, name: string): Promise<void> {
await page.getByTestId('sidebar-tab-setting').click()
await page.getByTestId('setting-new').click()
await page.getByTestId('setting-name-input').fill(name)
await page.locator('.dialog-content select').selectOption('character')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.getByTestId(/setting-item-/)).toContainText(name, { timeout: 10_000 })
}
test.describe('Character graph P7', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-graph-'))
})
test.afterEach(async () => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-GRAPH-01: relationship editor and graph modal', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '图谱测试书')
await createCharacterSetting(page, '角色甲')
await createCharacterSetting(page, '角色乙')
await page.getByText('角色甲').click()
await expect(page.getByTestId('setting-relationships')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('setting-relationship-add').click()
await page.locator('.setting-relationship-form select').selectOption({ label: '角色乙' })
await page.locator('.setting-relationship-form input[type="text"]').fill('挚友')
await page.locator('.setting-relationship-form .btn-primary').click()
await expect(page.getByTestId('setting-relationships')).toContainText('挚友')
await page.getByTestId('setting-open-graph').click()
await expect(page.getByTestId('character-graph-cy')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('graph-layout-circle').click()
await expect(page.getByTestId('character-graph-sidebar')).toBeVisible()
await app.close()
})
test('E2E-GRAPH-02: tap node shows sidebar with character name', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '图谱节点书')
await createCharacterSetting(page, '节点A')
await createCharacterSetting(page, '节点B')
await page.getByText('节点A').click()
await page.getByTestId('setting-relationship-add').click()
await page.locator('.setting-relationship-form select').selectOption({ label: '节点B' })
await page.locator('.setting-relationship-form input[type="text"]').fill('同门')
await page.locator('.setting-relationship-form .btn-primary').click()
await page.getByTestId('setting-open-graph').click()
await expect(page.getByTestId('character-graph-cy')).toBeVisible({ timeout: 10_000 })
const cyBox = await page.getByTestId('character-graph-cy').boundingBox()
expect(cyBox).toBeTruthy()
await page.getByTestId('character-graph-cy').click({
position: { x: (cyBox!.width ?? 200) / 2, y: (cyBox!.height ?? 200) / 2 }
})
await expect(page.getByTestId('character-graph-sidebar')).toContainText(/节点/, {
timeout: 5000
})
await app.close()
})
})
+58
View File
@@ -0,0 +1,58 @@
import { mkdtempSync, rmSync, existsSync, statSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import {
launchApp,
skipOnboarding,
createBookAndOpenEditor,
ensureChapterEditor,
openExportModal,
dismissCockpitIfOpen
} from './helpers'
test.describe('Export matrix', () => {
let userDataDir: string
let pdfPath: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-matrix-'))
pdfPath = join(userDataDir, 'chapter.pdf')
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-PDF-01: export chapter as PDF', async () => {
const app = await launchApp(userDataDir, { BILIN_E2E_PDF_SAVE: pdfPath })
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, 'PDF导出测试')
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror')
await editor.click()
await editor.pressSequentially('PDF导出测试正文。')
await page.keyboard.press('Control+S')
await openExportModal(page)
await page.getByTestId('export-kind-select').selectOption('matrix')
await page.getByTestId('export-matrix-format').selectOption('pdf')
await page.getByTestId('export-matrix-scope').selectOption('chapter')
await page.getByTestId('export-matrix-btn').click()
await expect(page.getByText(/已保存|Saved to/)).toBeVisible({ timeout: 30_000 })
expect(existsSync(pdfPath)).toBe(true)
expect(statSync(pdfPath).size).toBeGreaterThan(500)
await app.close()
})
})
+50
View File
@@ -0,0 +1,50 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import {
launchApp,
skipOnboarding,
createBookAndOpenEditor,
ensureChapterEditor,
openExportModal
} from './helpers'
test.describe('Export submission', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-export-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-EXPORT-01: copy submission format includes chapter heading', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '导出测试书')
await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror')
await editor.click()
await editor.pressSequentially('这是投稿导出测试正文。')
await page.keyboard.press('Control+S')
await expect(page.getByText('已保存')).toBeVisible({ timeout: 10_000 })
await openExportModal(page)
await expect(page.getByTestId('export-preview')).toContainText(/# 第\d+章/, { timeout: 10_000 })
await page.getByTestId('export-copy-clipboard').click()
await expect(page.getByText('已复制到剪贴板')).toBeVisible({ timeout: 10_000 })
await app.close()
})
})
+42
View File
@@ -0,0 +1,42 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import { launchApp, skipOnboarding, createBookAndOpenEditor, ensureChapterEditor } from './helpers'
test.describe('Focus mode', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-focus-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-FOCUS-01: Ctrl+Alt+F enters focus mode, Esc exits', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '专注模式书')
await ensureChapterEditor(page)
await page.getByTestId('topbar-focus-mode').click()
await expect(page.getByTestId('app-root')).toHaveClass(/focus-mode/, { timeout: 5000 })
await expect(page.locator('#left-sidebar')).toBeHidden()
await expect(page.getByTestId('focus-mode-exit')).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.getByTestId('app-root')).not.toHaveClass(/focus-mode/)
await expect(page.locator('#left-sidebar')).toBeVisible()
await app.close()
})
})
+95
View File
@@ -0,0 +1,95 @@
import path from 'path'
import { _electron as electron, type ElectronApplication, type Page, expect } from '@playwright/test'
export const ROOT = path.join(import.meta.dirname, '..')
export async function launchApp(
userDataDir: string,
extraEnv: Record<string, string> = {}
): Promise<ElectronApplication> {
return electron.launch({
args: [ROOT],
env: { ...process.env, BILIN_E2E: '1', BILIN_E2E_USER_DATA: userDataDir, ...extraEnv }
})
}
export async function skipOnboarding(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
if (await page.getByText('欢迎使用笔临').isVisible()) {
await page.getByRole('button', { name: '跳过' }).click()
}
}
export async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
if (await cockpit.isVisible({ timeout: 3000 }).catch(() => false)) {
await page.locator('[data-testid="cockpit-modal"] .modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
}
}
export async function createBookAndOpenEditor(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
}
export async function ensureChapterEditor(page: Page): Promise<void> {
const editor = page.locator('.ProseMirror')
if (await editor.isVisible().catch(() => false)) return
await page.getByRole('button', { name: '+ 新章' }).click()
await expect(editor).toBeVisible({ timeout: 10_000 })
}
export async function openInspirationModal(page: Page): Promise<void> {
await page.keyboard.press('Control+Shift+I')
const modal = page.getByTestId('inspiration-modal')
if (!(await modal.isVisible().catch(() => false))) {
await page.evaluate(() => window.dispatchEvent(new Event('bilin:e2e-capture-inspiration')))
}
await expect(modal).toBeVisible({ timeout: 10_000 })
}
export async function openExportModal(page: Page): Promise<void> {
await page.keyboard.press('Control+Shift+E')
const modal = page.getByTestId('export-modal')
if (!(await modal.isVisible().catch(() => false))) {
await page.getByTestId('open-export').click()
}
await expect(modal).toBeVisible({ timeout: 10_000 })
}
export async function openExportAllModal(page: Page): Promise<void> {
await page.getByTestId('home-export-all').click()
await expect(page.getByTestId('export-all-modal')).toBeVisible({ timeout: 10_000 })
}
export async function openImportBookModal(page: Page): Promise<void> {
await page.getByTestId('home-import-book').click()
await expect(page.getByTestId('import-book-modal')).toBeVisible({ timeout: 10_000 })
}
export async function openBookSettings(page: Page): Promise<void> {
await page.getByTestId('panel-tab-book-settings').click()
await expect(page.getByTestId('book-settings-tab')).toBeVisible({ timeout: 10_000 })
}
export async function openCharacterSetting(page: Page, name: string): Promise<void> {
await page.getByTestId('sidebar-tab-setting').click()
await page.getByTestId(/^setting-item-/).filter({ hasText: name }).first().click()
await expect(page.getByTestId('setting-relationships')).toBeVisible({ timeout: 15_000 })
}
export async function addCharacterSetting(page: Page, name: string): Promise<void> {
await page.getByTestId('sidebar-tab-setting').click()
await page.getByTestId('setting-new').click()
await page.getByTestId('setting-name-input').fill(name)
await page.locator('.dialog-content select').selectOption('character')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.getByTestId(/setting-item-/).filter({ hasText: name })).toBeVisible({
timeout: 10_000
})
}
+45
View File
@@ -0,0 +1,45 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import { launchApp, skipOnboarding, ROOT, dismissCockpitIfOpen } from './helpers'
const FIXTURE = join(ROOT, 'tests/fixtures/import-three-chapters.txt')
test.describe('Import book', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-import-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-IMPORT-01: import txt creates book with multiple chapters', async () => {
const app = await launchApp(userDataDir, { BILIN_E2E_IMPORT_FILE: FIXTURE })
const page = await app.firstWindow()
await skipOnboarding(page)
await page.getByTestId('home-import-book').click()
await expect(page.getByTestId('import-book-modal')).toBeVisible()
await page.getByTestId('import-pick-file').click()
await expect(page.getByTestId('import-file-path')).not.toHaveValue(/未选择|No file/)
await page.getByTestId('import-book-name').fill('导入E2E书')
await page.getByTestId('import-preview-btn').click()
await expect(page.getByTestId('import-preview')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('import-execute-btn').click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 30_000 })
await dismissCockpitIfOpen(page)
await expect(page.locator('.chapter-item')).toHaveCount(3, { timeout: 15_000 })
await app.close()
})
})
+45
View File
@@ -0,0 +1,45 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import {
launchApp,
skipOnboarding,
createBookAndOpenEditor,
openInspirationModal
} from './helpers'
test.describe('Inspiration capture', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-insp-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-INSP-01: home Ctrl+Shift+I saves idea with toast, stays on home', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '灵感测试书')
await page.locator('.tab.active').locator('span').last().click()
await expect(page.locator('#home-page')).toBeVisible({ timeout: 10_000 })
await openInspirationModal(page)
await page.getByTestId('inspiration-content-input').fill('主页捕获的灵感内容')
await page.getByTestId('inspiration-save-btn').click()
await expect(page.getByText('灵感已保存')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('#home-page')).toBeVisible()
await app.close()
})
})
+49
View File
@@ -0,0 +1,49 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import { launchApp, skipOnboarding, dismissCockpitIfOpen } from './helpers'
test.describe('Pack export all', () => {
let userDataDir: string
let packAllPath: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-pack-all-'))
packAllPath = join(userDataDir, 'backup.novel-all')
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-ALL-03: home export all books', async () => {
const app = await launchApp(userDataDir, { BILIN_E2E_PACK_SAVE: packAllPath })
const page = await app.firstWindow()
await skipOnboarding(page)
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill('批量导出书A')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: /笔临|Bilin/i }).first().click()
await expect(page.locator('#home-page')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('home-export-all').click()
await expect(page.getByTestId('export-all-modal')).toBeVisible()
await page.getByTestId('export-all-pick').click()
await expect(page.getByTestId('export-all-dest')).not.toHaveValue(/未选择|No destination/)
await page.getByTestId('export-all-start').click()
await expect(page.getByText(/已导出|exported/i)).toBeVisible({ timeout: 30_000 })
await app.close()
})
})
+74
View File
@@ -0,0 +1,74 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import {
launchApp,
skipOnboarding,
createBookAndOpenEditor,
ensureChapterEditor,
openExportModal,
dismissCockpitIfOpen
} from './helpers'
test.describe('Pack export/import', () => {
let userDataDir: string
let packPath: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-pack-'))
packPath = join(userDataDir, 'roundtrip.novel')
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-PACK-01: export .novel and re-import via UI', async () => {
const app = await launchApp(userDataDir, {
BILIN_E2E_PACK_SAVE: packPath,
BILIN_E2E_PACK_IMPORT_FILE: packPath
})
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, 'Pack Roundtrip')
await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror')
await editor.click()
await editor.pressSequentially('项目包导出导入测试正文。')
await page.keyboard.press('Control+S')
await expect(page.getByText('已保存')).toBeVisible({ timeout: 10_000 })
await openExportModal(page)
await page.getByTestId('export-kind-select').selectOption('novel')
await page.getByTestId('export-novel-btn').click()
await expect(page.getByText(/已导出/)).toBeVisible({ timeout: 15_000 })
await page.evaluate(async (dest) => {
const list = await window.electronAPI.book.list()
if (!list.ok || !list.data?.length) throw new Error('no books')
await window.electronAPI.book.delete(list.data[0].id)
}, packPath)
await page.getByRole('button', { name: /笔临|Bilin/i }).first().click()
await expect(page.locator('#home-page')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('home-import-book').click()
await expect(page.getByTestId('import-book-modal')).toBeVisible()
await page.getByTestId('import-pick-file').click()
await expect(page.getByTestId('import-pack-hint')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('import-execute-btn').click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 30_000 })
await dismissCockpitIfOpen(page)
await expect(page.locator('.chapter-item')).toHaveCount(1, { timeout: 15_000 })
await app.close()
})
})
+85
View File
@@ -0,0 +1,85 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import {
launchApp,
skipOnboarding,
createBookAndOpenEditor,
ensureChapterEditor,
dismissCockpitIfOpen
} from './helpers'
test.describe('Read mode', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-read-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-READ-01: open read mode shows merged chapter content', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '阅读模式测试')
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror')
await editor.click()
await editor.pressSequentially('这是连续阅读模式测试正文。')
await page.keyboard.press('Control+S')
await page.getByTestId('chapter-read-mode').click()
await expect(page.getByTestId('read-mode-overlay')).toBeVisible({ timeout: 10_000 })
await expect(page.getByTestId('read-mode-content')).toContainText('连续阅读模式测试正文')
await app.close()
})
test('E2E-READ-02: switch device width to phone', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '阅读宽度测试')
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
await page.getByTestId('chapter-read-mode').click()
await expect(page.getByTestId('read-mode-overlay')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('read-device-width').selectOption('phone')
await expect(page.getByTestId('read-mode-content')).toHaveCSS('max-width', '360px')
await app.close()
})
test('E2E-READ-03: font theme persists after exit and re-enter', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '阅读主题测试')
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
await page.getByTestId('chapter-read-mode').click()
await expect(page.getByTestId('read-mode-overlay')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('read-font-theme').selectOption('eye-care')
await expect(page.locator('.read-mode-font-eye-care')).toBeVisible()
await page.getByTestId('read-mode-close').click()
await expect(page.getByTestId('read-mode-overlay')).toBeHidden()
await page.getByTestId('chapter-read-mode').click()
await expect(page.locator('.read-mode-font-eye-care')).toBeVisible()
await app.close()
})
})
+51
View File
@@ -0,0 +1,51 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import {
launchApp,
skipOnboarding,
createBookAndOpenEditor,
ensureChapterEditor,
dismissCockpitIfOpen
} from './helpers'
test.describe('Cloud sync', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-sync-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-SYNC-01: configure local sync and run', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await page.getByRole('button', { name: /系统设置/ }).click()
await expect(page.locator('#settings-page')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('settings-nav-backup').click()
await expect(page.getByTestId('sync-settings-tab')).toBeVisible({ timeout: 10_000 })
const syncDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-sync-dir-'))
await page.getByTestId('sync-local-path').fill(syncDir)
await page.getByTestId('sync-password').fill('e2e-sync-password')
await page.getByTestId('sync-save-config').click()
await page.getByTestId('sync-run-now').click()
await expect(page.getByTestId('sync-progress').or(page.getByTestId('sync-last-status'))).toBeVisible({
timeout: 60_000
})
await app.close()
rmSync(syncDir, { recursive: true, force: true })
})
})
+127
View File
@@ -0,0 +1,127 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import {
launchApp,
skipOnboarding,
createBookAndOpenEditor,
ensureChapterEditor,
dismissCockpitIfOpen,
addCharacterSetting,
openCharacterSetting
} from './helpers'
async function openChapterByIndex(page: import('@playwright/test').Page, index = 0): Promise<void> {
await page.getByTestId('sidebar-tab-chapters').click()
await page.getByTestId(/^chapter-item-/).nth(index).click()
await expect(page.getByTestId('chapter-meta-open')).toBeVisible({ timeout: 10_000 })
}
async function setChapterStoryTime(
page: import('@playwright/test').Page,
storyTime: string,
chapterIndex = 0
): Promise<void> {
await openChapterByIndex(page, chapterIndex)
await page.getByTestId('chapter-meta-open').click()
await expect(page.getByTestId('chapter-meta-panel')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('chapter-meta-story-time').fill(storyTime)
await page.getByTestId('chapter-meta-save').click()
await expect(page.getByTestId('chapter-meta-panel')).toBeHidden({ timeout: 10_000 })
}
test.describe('Story timeline', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-timeline-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-TL-01: open story timeline lists chapters', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '时间线测试书')
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
await setChapterStoryTime(page, '730')
await page.getByTestId('open-timeline').click()
await expect(page.getByTestId('timeline-modal')).toBeVisible({ timeout: 10_000 })
await expect(page.getByTestId('timeline-list')).toContainText('730')
await app.close()
})
test('E2E-TL-02: character birth year and chapter times show in timeline', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '角色年龄测试')
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
await addCharacterSetting(page, '林远')
await openCharacterSetting(page, '林远')
await expect(page.getByTestId('character-arc-panel')).toBeVisible({ timeout: 15_000 })
await page.getByTestId('character-birth-date').fill('700')
await page.getByRole('button', { name: '保存' }).click()
await page.getByTestId('sidebar-tab-chapters').click()
await openChapterByIndex(page, 0)
await setChapterStoryTime(page, '710', 0)
await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
await setChapterStoryTime(page, '730', 1)
await page.getByTestId('open-timeline').click()
await expect(page.getByTestId('timeline-modal')).toBeVisible({ timeout: 10_000 })
await expect(page.getByTestId('timeline-list')).toContainText('710')
await expect(page.getByTestId('timeline-list')).toContainText('730')
await expect(page.locator('.timeline-item--conflict')).toHaveCount(0)
await app.close()
})
test('E2E-TL-03: timeline conflict shows red warning', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '时间冲突测试')
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
await addCharacterSetting(page, '主角')
await openCharacterSetting(page, '主角')
await expect(page.getByTestId('character-arc-panel')).toBeVisible({ timeout: 15_000 })
await page.getByTestId('character-birth-date').fill('700')
await page.getByRole('button', { name: '保存' }).click()
await page.getByTestId('sidebar-tab-chapters').click()
await openChapterByIndex(page, 0)
await setChapterStoryTime(page, '730', 0)
await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
await setChapterStoryTime(page, '720', 1)
await page.getByTestId('open-timeline').click()
await expect(page.getByTestId('timeline-modal')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('.timeline-item--conflict')).toHaveCount(1, { timeout: 10_000 })
await expect(page.getByTestId('timeline-conflict-msg')).toBeVisible()
await app.close()
})
})
+59
View File
@@ -0,0 +1,59 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import {
launchApp,
skipOnboarding,
createBookAndOpenEditor,
ensureChapterEditor,
dismissCockpitIfOpen
} from './helpers'
test.describe('Persistent undo', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-undo-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-UNDO-01: ctrl+z after reopen restores previous content', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createBookAndOpenEditor(page, '撤销测试书')
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror')
await editor.click()
await editor.pressSequentially('持久化撤销测试')
await page.keyboard.press('Control+S')
await page.waitForTimeout(2000)
await page.getByRole('button', { name: /笔临/ }).click()
await expect(page.locator('#home-page')).toBeVisible({ timeout: 15_000 })
await page.locator('.book-name', { hasText: '撤销测试书' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
await ensureChapterEditor(page)
await expect(editor).toContainText('持久化撤销测试')
await editor.click()
await page.keyboard.press('Control+Z')
await expect(editor).not.toContainText('持久化撤销测试', { timeout: 10_000 })
await app.close()
})
})
+36
View File
@@ -0,0 +1,36 @@
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import { launchApp, skipOnboarding } from './helpers'
test.describe('Auto update', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-update-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-UPDATE-02: mock update shows banner and download flow', async () => {
const app = await launchApp(userDataDir, {
BILIN_MOCK_UPDATE: '1',
BILIN_MOCK_UPDATE_VERSION: '9.9.9'
})
const page = await app.firstWindow()
await skipOnboarding(page)
await expect(page.getByTestId('update-banner')).toBeVisible({ timeout: 15_000 })
await page.getByTestId('update-download').click()
await expect(page.getByTestId('update-install')).toBeVisible({ timeout: 15_000 })
await app.close()
})
})
+119
View File
@@ -0,0 +1,119 @@
import path from 'path'
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect, _electron as electron, type Page } from '@playwright/test'
const ROOT = path.join(import.meta.dirname, '..')
async function launchApp(userDataDir: string) {
return electron.launch({
args: [ROOT],
env: {
...process.env,
BILIN_E2E: '1',
BILIN_E2E_USER_DATA: userDataDir
}
})
}
async function skipOnboarding(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
if (await page.getByText('欢迎使用笔临').isVisible()) {
await page.getByRole('button', { name: '跳过' }).click()
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
}
}
async function dismissCockpitIfOpen(page: Page): Promise<void> {
const cockpit = page.locator('[data-testid="cockpit-modal"]')
if (await cockpit.isVisible({ timeout: 3000 }).catch(() => false)) {
await page.locator('[data-testid="cockpit-modal"] .modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
}
}
async function createAndOpenBook(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
await dismissCockpitIfOpen(page)
}
async function ensureChapterEditor(page: Page): Promise<void> {
const items = page.locator('.chapter-item')
if ((await items.count()) === 0) {
await page.getByRole('button', { name: '+ 新章' }).click()
} else {
await items.first().click()
}
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
}
test.describe('Writing analytics P9', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-analytics-'))
})
test.afterEach(async () => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-ANALYTICS-01: writing updates cockpit speed card', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '分析测试书')
await ensureChapterEditor(page)
const editor = page.locator('.ProseMirror')
await editor.click()
await editor.pressSequentially(
'这是一段用于测试写作分析速度的示例文字,需要足够长度才能写入写作会话并计算今日字速。继续添加一些汉字以确保字数统计生效。'
)
await page.waitForTimeout(1500)
await page.locator('[data-testid="topbar-cockpit"]').click()
await expect(page.locator('[data-testid="cockpit-modal"]')).toBeVisible()
await expect(page.locator('[data-testid="cockpit-analytics"]')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('[data-testid="cockpit-speed-today"]')).not.toHaveText('', {
timeout: 10_000
})
await app.close()
})
test('E2E-ANALYTICS-02: chapter POV appears in cockpit chart', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page, '视角分析书')
await ensureChapterEditor(page)
await page.getByTestId('sidebar-tab-setting').click()
await page.getByTestId('setting-new').click()
await page.getByTestId('setting-name-input').fill('主角视角')
await page.locator('.dialog-content select').selectOption('character')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.getByText('主角视角')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('sidebar-tab-chapter').click()
await ensureChapterEditor(page)
await page.getByTestId('chapter-pov-select').selectOption({ label: '主角视角' })
await page.locator('[data-testid="topbar-cockpit"]').click()
await expect(page.locator('[data-testid="cockpit-pov-chart"]')).toContainText('主角视角', {
timeout: 10_000
})
await app.close()
})
})
+7 -2
View File
@@ -8,8 +8,13 @@ export default defineConfig({
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/main/index.ts')
}
index: resolve(__dirname, 'src/main/index.ts'),
'import-bootstrap': resolve(__dirname, 'src/main/import-bootstrap.ts'),
'pack-bootstrap': resolve(__dirname, 'src/main/pack-bootstrap.ts'),
'export-matrix-bootstrap': resolve(__dirname, 'src/main/export-matrix-bootstrap.ts'),
'sync-bootstrap': resolve(__dirname, 'src/main/sync-bootstrap.ts')
},
external: ['mammoth', 'marked', 'archiver', 'extract-zip', 'docx', 'electron-log', 'electron-updater']
}
}
},
+1067 -201
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bilin",
"version": "0.9.0",
"version": "2.0.0",
"description": "笔临 - 长篇创作智能协作平台",
"main": "./out/main/index.js",
"type": "module",
@@ -28,10 +28,19 @@
"@tiptap/extension-underline": "^3.27.1",
"@tiptap/react": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1",
"archiver": "^8.0.0",
"cytoscape": "^3.34.0",
"cytoscape-cose-bilkent": "^4.1.0",
"diff-match-patch": "^1.0.5",
"docx": "^9.7.1",
"electron": "^36.9.0",
"electron-log": "^5.4.4",
"electron-updater": "^6.8.9",
"extract-zip": "^2.0.1",
"i18next": "^24.2.3",
"jotai": "^2.12.1",
"mammoth": "^1.12.0",
"marked": "^15.0.12",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.4.1",
@@ -40,7 +49,11 @@
},
"devDependencies": {
"@playwright/test": "^1.51.0",
"@types/archiver": "^8.0.0",
"@types/cytoscape": "^3.21.9",
"@types/diff-match-patch": "^1.0.36",
"@types/extract-zip": "^2.0.0",
"@types/marked": "^6.0.0",
"@types/node": "^22.13.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
+261 -1
View File
@@ -221,11 +221,39 @@
"cockpit.resumeEdit": "Resume writing",
"cockpit.bridgeCheck": "Chapter bridge check",
"cockpit.forgottenForeshadow": "Forgotten foreshadows",
"cockpit.todayOutlineTasks": "Today's outline tasks",
"cockpit.outlineTargetWords": "Target {{count}} words",
"cockpit.todayProgress": "Today {{current}} / {{goal}} words",
"cockpit.streak": "{{days}}-day streak",
"cockpit.heatmap": "Writing heatmap",
"cockpit.achievements": "Writing achievements",
"cockpit.pomodoroToday": "{{count}} pomodoros today",
"cockpit.openGraph": "Character relationship graph",
"graph.title": "Character relationship graph",
"graph.layout.cose": "Force-directed",
"graph.layout.grid": "Grid",
"graph.layout.circle": "Circle",
"graph.truncated": "Too many characters; showing first {{count}} only",
"graph.sidebar.node": "Character: {{name}}",
"graph.sidebar.edge": "Relationship: {{label}}",
"graph.sidebar.hint": "Tap a node or edge for details",
"graph.editSetting": "Edit setting",
"graph.filter.all": "All characters",
"graph.filter.connected": "Connected only",
"relationship.title": "Character relationships",
"relationship.add": "Add relationship",
"relationship.target": "Target character",
"relationship.label": "Relationship label",
"relationship.intimacy": "Intimacy",
"analytics.title": "Writing analytics",
"analytics.speedToday": "Today {{wpm}} words/min",
"analytics.speedUnknown": "No speed data yet",
"analytics.hourlyHeatmap": "Writing hours (last 30 days)",
"analytics.povDistribution": "POV word distribution",
"analytics.volumeDistribution": "Words by volume",
"analytics.weekTrend": "Last 7 days",
"analytics.povUnassigned": "Unassigned POV",
"chapter.pov": "POV character",
"pomodoro.start": "Start pomodoro",
"pomodoro.pause": "Pause",
"pomodoro.resume": "Resume",
@@ -246,6 +274,30 @@
"settings.pomodoroDuration": "Pomodoro duration (minutes)",
"settings.systemNotifications": "Enable system notifications",
"settings.goalNotifications": "Enable goal notifications",
"settings.focusAmbientSound": "Focus mode ambient sound",
"settings.complianceCheck": "Enable sensitive word compliance check",
"settings.complianceWords": "Custom sensitive words",
"settings.complianceWordsHint": "One word per line; merged with built-in list",
"timeline.open": "Story timeline",
"timeline.title": "Story timeline",
"timeline.empty": "No timeline events yet",
"timeline.noTime": "No time set",
"timeline.manual": "Manual event",
"timeline.addEvent": "Add event",
"timeline.jumpChapter": "Go to chapter",
"timeline.eventTitlePrompt": "Event title",
"timeline.storyTimePrompt": "Story time (e.g. 732 or +10y)",
"timeline.characterHint": "Set character birth year in the arc panel for conflict detection",
"arc.title": "Character arc",
"arc.birthDate": "Birth year",
"arc.empty": "No arc nodes yet; knowledge entries will appear as you write",
"arc.jumpChapter": "Go to chapter",
"reference.openInEditor": "Open in editor",
"reference.detailTitle": "Reference detail",
"ai.settings.customSlashCommands": "Custom slash commands",
"ai.settings.customSlashName": "Command (e.g. /outline)",
"ai.settings.customSlashTemplate": "Expansion template",
"ai.settings.customSlashAdd": "Add command",
"bridge.title": "Chapter bridge",
"bridge.loading": "Analyzing…",
"bridge.previousTail": "Previous chapter ending",
@@ -374,5 +426,213 @@
"landmark.created": "Landmark inserted",
"landmark.chapterOnly": "Insert landmarks in a chapter",
"wordfreq.hint": "Top 100 words in current scope",
"wordfreq.aiNaming": "AI naming suggestions"
"wordfreq.aiNaming": "AI naming suggestions",
"settings.templates": "Chapter Templates",
"settings.submission": "Submission Presets",
"dialog.edit": "Edit",
"dialog.delete": "Delete",
"template.title": "Chapter Templates",
"template.quickNew": "New from Template",
"template.builtin": "Built-in",
"template.custom": "Custom",
"template.selectTemplate": "Select template",
"template.chapterTitle": "Chapter title",
"template.chapterTitlePlaceholder": "Enter chapter title",
"template.createChapter": "Create chapter",
"template.add": "Add template",
"template.newTemplate": "New template",
"template.name": "Template name",
"template.body": "Template body",
"import.title": "Import Book",
"import.pickFile": "Browse file",
"import.noFile": "No file selected",
"import.splitMode": "Split mode",
"import.splitAuto": "Auto-detect chapters",
"import.splitParagraph": "Split by blank lines",
"import.splitRegex": "Custom regex",
"import.customRegex": "Regex pattern",
"import.preview": "Preview chapters",
"import.chapterCount": "{{count}} chapter(s)",
"import.largeFileConfirm": "Large file ({{size}}). Import may take 30s2min. Continue?",
"import.progress": "Importing {{current}}/{{total}}",
"import.start": "Start import",
"import.importing": "Importing…",
"import.success": "Import complete",
"import.failed": "Import failed",
"export.title": "Export Chapter",
"export.submissionPresets": "Submission preset",
"export.copyClipboard": "Copy to clipboard",
"export.saveTxt": "Save as text",
"export.copied": "Copied to clipboard",
"export.saved": "Saved to {{path}}",
"export.preview": "Preview",
"export.loading": "Loading…",
"export.noPreview": "(no preview)",
"export.kind": "Export type",
"export.kindSubmission": "Submission format (current chapter)",
"export.kindNovel": "Bilin project pack (.novel)",
"export.novelDesc": "Export the full book database, metadata, and attachments. Restore via Import Book.",
"export.exportNovel": "Export project pack",
"export.kindMatrix": "Standard export (txt/md/docx/pdf)",
"export.matrixFormat": "File format",
"export.matrixScope": "Export scope",
"export.matrixOptions": "Options",
"export.scopeChapter": "Current chapter",
"export.scopeVolume": "Current volume",
"export.scopeBook": "Entire book",
"export.optionAnnotations": "Include annotations/landmarks",
"export.optionAiMark": "Mark AI-generated content",
"export.matrixStart": "Export file",
"export.matrixSaved": "Saved to {{path}}",
"export.matrixFailed": "Export failed",
"export.exporting": "Exporting…",
"import.novelHint": "Imports the entire book from a .novel pack (chapters, settings, knowledge).",
"import.novelAllHint": "Imports all books from a .novel-all pack. Choose a conflict strategy.",
"pack.exportAllTitle": "Export all books",
"pack.exportAllDesc": "Package all books and global settings into a .novel-all file.",
"pack.estimating": "Estimating size…",
"pack.estimatedSize": "Estimated size: {{size}}",
"pack.noDest": "No destination selected",
"pack.pickDest": "Choose location",
"pack.largeExportConfirm": "Export is large ({{size}}). Continue?",
"pack.exportAllStart": "Start export",
"pack.exportAllSuccess": "All books exported",
"pack.exportAllFailed": "Export failed",
"pack.exportBookSuccess": "\"{{name}}\" exported as project pack",
"pack.exportBookFailed": "Project pack export failed",
"pack.exporting": "Exporting…",
"pack.cancel": "Cancel",
"pack.progress": "Progress {{current}}/{{total}}",
"pack.importStrategy": "Import strategy",
"pack.strategyNew": "Import all as new books",
"pack.strategySkip": "Skip existing books",
"pack.strategyOverwrite": "Overwrite matching books",
"pack.importAllSuccess": "Import done: {{imported}} new, {{skipped}} skipped, {{overwritten}} overwritten",
"backup.desc": "Export books as Bilin project packs for backup, migration, or restore.",
"backup.exportCurrentBook": "Export current book",
"backup.exportBookBtn": "Export as .novel",
"backup.exportAllBooks": "Export all books",
"backup.exportAllBtn": "Open bulk export",
"backup.noBook": "Open a book in the editor first",
"backup.openBookHint": "Open a book in the editor to export a single project pack",
"backup.exportBookSuccess": "\"{{name}}\" exported",
"backup.exportBookFailed": "Export failed",
"backup.formatHint": "Bulk exports over 500 MB require extra confirmation.",
"submission.title": "Submission Presets",
"submission.titleFormat": "Title format",
"submission.indent": "Indent paragraphs",
"submission.indentOn": "Indent: on",
"submission.indentOff": "Indent: off",
"submission.includeAuthorsNote": "Include author's note",
"submission.paragraphGap": "Paragraph spacing",
"submission.gapSingle": "Single spacing",
"submission.gapDouble": "Double spacing",
"submission.add": "Add preset",
"submission.newPreset": "New preset",
"submission.name": "Preset name",
"submission.setDefault": "Set as default",
"inspiration.selectBook": "Link to book",
"inspiration.saved": "Idea saved",
"inspiration.noBooks": "Create a book first to save ideas",
"bookSettings.title": "Book settings",
"bookSettings.synopsis": "Synopsis",
"bookSettings.cover": "Cover",
"bookSettings.pickCover": "Choose cover image",
"bookSettings.noCover": "Not set",
"bookSettings.alwaysShowCockpit": "Show cockpit on every open",
"bookSettings.dangerZone": "Danger zone",
"bookSettings.deleteBook": "Delete this book",
"bookSettings.deleteConfirm": "Delete \"{{name}}\"? This cannot be undone.",
"volume.rename": "Rename volume",
"volume.renamePrompt": "Volume name",
"volume.editDescription": "Edit description",
"volume.descriptionPrompt": "Volume description",
"volume.delete": "Delete volume",
"volume.deleteConfirm": "Delete volume \"{{name}}\"?",
"volume.deleteHasChapters": "Cannot delete a volume that still has chapters",
"chapter.delete": "Delete chapter",
"chapter.deleteConfirm": "Delete chapter \"{{title}}\"?",
"chapterMeta.title": "Chapter metadata",
"chapterMeta.open": "Meta",
"chapterMeta.summary": "Summary",
"chapterMeta.storyTime": "Story time",
"chapterMeta.wordThreshold": "Word count threshold",
"chapterMeta.tags": "Tags",
"chapterMeta.tagPlaceholder": "Enter tag",
"chapterMeta.addTag": "Add",
"chapterMeta.publishedAt": "Published {{date}}",
"ai.session.rename": "Rename session",
"ai.session.renamePrompt": "Session title",
"ai.session.archive": "Archive",
"ai.session.unarchive": "Unarchive",
"ai.session.export": "Export",
"ai.session.exportMd": "Markdown (.md)",
"ai.session.exportTxt": "Plain text (.txt)",
"ai.session.delete": "Delete session",
"ai.session.deleteConfirm": "Delete session \"{{title}}\"?",
"ai.session.active": "Active",
"ai.session.archived": "Archived",
"focus.toggle": "Focus mode",
"focus.exit": "Exit focus",
"tts.started": "Reading aloud",
"tts.stopped": "Stopped reading",
"tts.noText": "Nothing to read",
"home.exportAllWave2": "Bulk export coming in a later release",
"readMode.title": "Read mode",
"readMode.open": "Read mode",
"readMode.exit": "Exit reading",
"readMode.deviceWidth": "Device width",
"readMode.devicePhone": "Phone (360px)",
"readMode.deviceTablet": "Tablet (768px)",
"readMode.deviceDesktop": "Desktop (100%)",
"readMode.fontTheme": "Font theme",
"readMode.fontSerif": "Serif",
"readMode.fontSans": "Sans-serif",
"readMode.fontEyeCare": "Eye care",
"readMode.tts": "Read aloud",
"settings.extensions": "Extensions",
"sync.title": "Cloud sync",
"sync.desc": "Sync encrypted backup of all books to WebDAV or a local folder",
"sync.mode": "Sync mode",
"sync.modeLocal": "Local folder",
"sync.modeWebdav": "WebDAV",
"sync.localPathPlaceholder": "Sync folder path",
"sync.pickFolder": "Pick folder",
"sync.webdavUrl": "WebDAV URL",
"sync.webdavUser": "WebDAV username",
"sync.schedule": "Schedule",
"sync.scheduleManual": "Manual",
"sync.scheduleHourly": "Hourly",
"sync.scheduleDaily": "Daily",
"sync.scheduleWeekly": "Weekly",
"sync.passwordPlaceholder": "Sync password (min 8 chars)",
"sync.passwordHint": "Set a sync password with at least 8 characters",
"sync.saveConfig": "Save sync config",
"sync.runNow": "Sync now",
"sync.running": "Syncing…",
"sync.done": "Sync complete",
"sync.configSaved": "Sync config saved",
"sync.lastSync": "Last sync: {{time}} ({{status}})",
"sync.conflictTitle": "Sync conflict",
"sync.conflictDesc": "Both local and remote changed. Choose which copy to keep.",
"sync.useRemote": "Use remote",
"sync.useLocal": "Use local",
"extensions.title": "Extensions",
"extensions.desc": "Extension API skeleton (no third-party code execution)",
"extensions.devMode": "Developer mode (manifest validation only)",
"extensions.stubHint": "Full extension marketplace coming in a future release.",
"update.available": "Update {{version}} available",
"update.download": "Download update",
"update.downloading": "Downloading {{percent}}%",
"update.install": "Restart to install",
"crash.title": "Unclean shutdown detected",
"crash.desc": "The app may have exited unexpectedly last time. You can send a local log report (stub).",
"crash.send": "Send report",
"crash.dismiss": "Dismiss",
"ai.settings.builtinSkills": "Built-in skills",
"ai.settings.skillContinue": "Continue writing",
"ai.settings.skillPolish": "Polish",
"ai.settings.skillProofread": "Proofread",
"ai.settings.mcpConnector": "MCP connector URL",
"ai.settings.mcpTest": "Test connection"
}
+261 -1
View File
@@ -221,11 +221,39 @@
"cockpit.resumeEdit": "继续写作",
"cockpit.bridgeCheck": "章间衔接检查",
"cockpit.forgottenForeshadow": "查看遗忘伏笔",
"cockpit.todayOutlineTasks": "今日大纲任务",
"cockpit.outlineTargetWords": "目标 {{count}} 字",
"cockpit.todayProgress": "今日 {{current}} / {{goal}} 字",
"cockpit.streak": "连更 {{days}} 天",
"cockpit.heatmap": "写作热力图",
"cockpit.achievements": "写作成就",
"cockpit.pomodoroToday": "今日番茄 {{count}} 次",
"cockpit.openGraph": "角色关系图谱",
"graph.title": "角色关系图谱",
"graph.layout.cose": "力导向",
"graph.layout.grid": "网格",
"graph.layout.circle": "圆形",
"graph.truncated": "角色过多,仅显示前 {{count}} 个",
"graph.sidebar.node": "角色:{{name}}",
"graph.sidebar.edge": "关系:{{label}}",
"graph.sidebar.hint": "点击节点或边查看详情",
"graph.editSetting": "编辑设定",
"graph.filter.all": "全部角色",
"graph.filter.connected": "仅有关系",
"relationship.title": "角色关系",
"relationship.add": "添加关系",
"relationship.target": "目标角色",
"relationship.label": "关系描述",
"relationship.intimacy": "亲密度",
"analytics.title": "写作分析",
"analytics.speedToday": "今日 {{wpm}} 字/分钟",
"analytics.speedUnknown": "暂无速度数据",
"analytics.hourlyHeatmap": "写作时段(近30天)",
"analytics.povDistribution": "视角字数分布",
"analytics.volumeDistribution": "按卷字数",
"analytics.weekTrend": "近 7 日字数",
"analytics.povUnassigned": "未指定视角",
"chapter.pov": "视角角色",
"pomodoro.start": "开始番茄钟",
"pomodoro.pause": "暂停",
"pomodoro.resume": "继续",
@@ -246,6 +274,30 @@
"settings.pomodoroDuration": "番茄钟时长(分钟)",
"settings.systemNotifications": "启用系统通知",
"settings.goalNotifications": "启用目标达成提醒",
"settings.focusAmbientSound": "专注模式环境音效",
"settings.complianceCheck": "启用敏感词合规检查",
"settings.complianceWords": "自定义敏感词库",
"settings.complianceWordsHint": "每行一个词,将与内置词库合并扫描",
"timeline.open": "故事时间线",
"timeline.title": "故事时间线",
"timeline.empty": "暂无时间线事件",
"timeline.noTime": "未标记时间",
"timeline.manual": "手动事件",
"timeline.addEvent": "添加事件",
"timeline.jumpChapter": "跳转章节",
"timeline.eventTitlePrompt": "事件标题",
"timeline.storyTimePrompt": "故事时间(如 732 或 +10y",
"timeline.characterHint": "角色出生日期可在角色设定弧线面板中配置,用于冲突检测",
"arc.title": "角色弧线",
"arc.birthDate": "出生年份",
"arc.empty": "暂无弧线节点,写作后知识库会自动聚合",
"arc.jumpChapter": "跳转章节",
"reference.openInEditor": "在编辑器中打开",
"reference.detailTitle": "引用详情",
"ai.settings.customSlashCommands": "自定义快捷命令",
"ai.settings.customSlashName": "命令名(如 /大纲)",
"ai.settings.customSlashTemplate": "展开模板",
"ai.settings.customSlashAdd": "添加命令",
"bridge.title": "章间衔接",
"bridge.loading": "分析中…",
"bridge.previousTail": "上一章末尾",
@@ -374,5 +426,213 @@
"landmark.created": "地标已插入",
"landmark.chapterOnly": "请在章节中插入地标",
"wordfreq.hint": "当前范围词频 Top 100",
"wordfreq.aiNaming": "AI 命名建议"
"wordfreq.aiNaming": "AI 命名建议",
"settings.templates": "章节模板",
"settings.submission": "投稿预设",
"dialog.edit": "编辑",
"dialog.delete": "删除",
"template.title": "章节模板",
"template.quickNew": "模板新建章",
"template.builtin": "内置",
"template.custom": "自定义",
"template.selectTemplate": "选择模板",
"template.chapterTitle": "章节标题",
"template.chapterTitlePlaceholder": "输入本章标题",
"template.createChapter": "创建章节",
"template.add": "新建模板",
"template.newTemplate": "新模板",
"template.name": "模板名称",
"template.body": "模板正文",
"import.title": "导入书籍",
"import.pickFile": "浏览文件",
"import.noFile": "未选择文件",
"import.splitMode": "分章规则",
"import.splitAuto": "自动识别章节",
"import.splitParagraph": "双换行分段",
"import.splitRegex": "自定义正则",
"import.customRegex": "正则表达式",
"import.preview": "预览分章",
"import.chapterCount": "共 {{count}} 章",
"import.largeFileConfirm": "文件较大({{size}}),导入可能需要 30 秒至 2 分钟,是否继续?",
"import.progress": "正在导入 {{current}}/{{total}}",
"import.start": "开始导入",
"import.importing": "导入中…",
"import.success": "导入成功",
"import.failed": "导入失败",
"export.title": "导出章节",
"export.submissionPresets": "投稿预设",
"export.copyClipboard": "复制到剪贴板",
"export.saveTxt": "另存为文本",
"export.copied": "已复制到剪贴板",
"export.saved": "已保存至 {{path}}",
"export.preview": "预览",
"export.loading": "加载中…",
"export.noPreview": "(无预览)",
"export.kind": "导出类型",
"export.kindSubmission": "投稿格式(当前章节)",
"export.kindNovel": "笔临项目包 (.novel)",
"export.novelDesc": "导出整本书的数据库、元数据与附件,可在其他设备或备份后通过「导入书籍」恢复。",
"export.exportNovel": "导出项目包",
"export.kindMatrix": "常规导出(txt/md/docx/pdf",
"export.matrixFormat": "文件格式",
"export.matrixScope": "导出范围",
"export.matrixOptions": "导出选项",
"export.scopeChapter": "当前章节",
"export.scopeVolume": "当前分卷",
"export.scopeBook": "全书",
"export.optionAnnotations": "包含批注/地标",
"export.optionAiMark": "标注 AI 生成内容",
"export.matrixStart": "导出文件",
"export.matrixSaved": "已保存至 {{path}}",
"export.matrixFailed": "导出失败",
"export.exporting": "导出中…",
"import.novelHint": "将导入 .novel 项目包中的整本书(含章节、设定与知识库)。",
"import.novelAllHint": "将批量导入 .novel-all 包中的所有书籍,请选择冲突处理策略。",
"pack.exportAllTitle": "导出所有书籍",
"pack.exportAllDesc": "将所有书籍与全局设置打包为 .novel-all 文件。",
"pack.estimating": "正在估算大小…",
"pack.estimatedSize": "预计大小:{{size}}",
"pack.noDest": "未选择保存路径",
"pack.pickDest": "选择保存位置",
"pack.largeExportConfirm": "导出体积较大({{size}}),确认继续?",
"pack.exportAllStart": "开始导出",
"pack.exportAllSuccess": "全部书籍已导出",
"pack.exportAllFailed": "导出失败",
"pack.exportBookSuccess": "「{{name}}」已导出为项目包",
"pack.exportBookFailed": "项目包导出失败",
"pack.exporting": "导出中…",
"pack.cancel": "取消",
"pack.progress": "进度 {{current}}/{{total}}",
"pack.importStrategy": "导入策略",
"pack.strategyNew": "全部作为新书导入",
"pack.strategySkip": "跳过已存在的书",
"pack.strategyOverwrite": "覆盖同名书籍",
"pack.importAllSuccess": "导入完成:新增 {{imported}},跳过 {{skipped}},覆盖 {{overwritten}}",
"backup.desc": "将书籍导出为笔临项目包,便于备份、迁移或在其他设备恢复。",
"backup.exportCurrentBook": "导出当前书籍",
"backup.exportBookBtn": "导出为 .novel",
"backup.exportAllBooks": "导出全部书籍",
"backup.exportAllBtn": "打开批量导出",
"backup.noBook": "请先在编辑器中打开一本书",
"backup.openBookHint": "在编辑器中打开书籍后可导出单书项目包",
"backup.exportBookSuccess": "「{{name}}」已导出",
"backup.exportBookFailed": "导出失败",
"backup.formatHint": "超过 500 MB 的批量导出需要额外确认。",
"submission.title": "投稿预设",
"submission.titleFormat": "标题格式",
"submission.indent": "段首空两格",
"submission.indentOn": "段首缩进:开",
"submission.indentOff": "段首缩进:关",
"submission.includeAuthorsNote": "包含作者的话",
"submission.paragraphGap": "段落间距",
"submission.gapSingle": "单倍行距",
"submission.gapDouble": "双倍行距",
"submission.add": "新建预设",
"submission.newPreset": "新投稿预设",
"submission.name": "预设名称",
"submission.setDefault": "设为默认预设",
"inspiration.selectBook": "关联书籍",
"inspiration.saved": "灵感已保存",
"inspiration.noBooks": "请先创建一本书再保存灵感",
"bookSettings.title": "书籍设置",
"bookSettings.synopsis": "简介",
"bookSettings.cover": "封面",
"bookSettings.pickCover": "选择封面图片",
"bookSettings.noCover": "未设置",
"bookSettings.alwaysShowCockpit": "每次打开显示驾驶舱",
"bookSettings.dangerZone": "危险操作",
"bookSettings.deleteBook": "删除此书",
"bookSettings.deleteConfirm": "确定删除「{{name}}」?此操作不可恢复。",
"volume.rename": "重命名分卷",
"volume.renamePrompt": "分卷名称",
"volume.editDescription": "编辑简述",
"volume.descriptionPrompt": "分卷简述",
"volume.delete": "删除分卷",
"volume.deleteConfirm": "确定删除分卷「{{name}}」?",
"volume.deleteHasChapters": "分卷内仍有章节,无法删除",
"chapter.delete": "删除章节",
"chapter.deleteConfirm": "确定删除章节「{{title}}」?",
"chapterMeta.title": "章节元数据",
"chapterMeta.open": "元数据",
"chapterMeta.summary": "章节摘要",
"chapterMeta.storyTime": "故事时间",
"chapterMeta.wordThreshold": "字数阈值",
"chapterMeta.tags": "标签",
"chapterMeta.tagPlaceholder": "输入标签",
"chapterMeta.addTag": "添加",
"chapterMeta.publishedAt": "发布于 {{date}}",
"ai.session.rename": "重命名会话",
"ai.session.renamePrompt": "会话标题",
"ai.session.archive": "归档",
"ai.session.unarchive": "取消归档",
"ai.session.export": "导出",
"ai.session.exportMd": "Markdown (.md)",
"ai.session.exportTxt": "纯文本 (.txt)",
"ai.session.delete": "删除会话",
"ai.session.deleteConfirm": "确定删除会话「{{title}}」?",
"ai.session.active": "活跃",
"ai.session.archived": "已归档",
"focus.toggle": "专注模式",
"focus.exit": "退出专注",
"tts.started": "开始朗读",
"tts.stopped": "已停止朗读",
"tts.noText": "没有可朗读的内容",
"home.exportAllWave2": "批量导出将在后续版本提供",
"readMode.title": "阅读模式",
"readMode.open": "阅读模式",
"readMode.exit": "退出阅读",
"readMode.deviceWidth": "设备宽度",
"readMode.devicePhone": "手机 (360px)",
"readMode.deviceTablet": "平板 (768px)",
"readMode.deviceDesktop": "桌面 (100%)",
"readMode.fontTheme": "字体主题",
"readMode.fontSerif": "衬线",
"readMode.fontSans": "无衬线",
"readMode.fontEyeCare": "护眼",
"readMode.tts": "朗读",
"settings.extensions": "扩展",
"sync.title": "云同步",
"sync.desc": "将加密后的全部书籍备份同步到 WebDAV 或本地云盘目录",
"sync.mode": "同步方式",
"sync.modeLocal": "本地目录",
"sync.modeWebdav": "WebDAV",
"sync.localPathPlaceholder": "选择或输入同步目录路径",
"sync.pickFolder": "选择目录",
"sync.webdavUrl": "WebDAV 地址",
"sync.webdavUser": "WebDAV 用户名",
"sync.schedule": "同步计划",
"sync.scheduleManual": "手动",
"sync.scheduleHourly": "每小时",
"sync.scheduleDaily": "每天",
"sync.scheduleWeekly": "每周",
"sync.passwordPlaceholder": "同步密码(至少 8 位)",
"sync.passwordHint": "请设置至少 8 位的同步密码",
"sync.saveConfig": "保存同步配置",
"sync.runNow": "立即同步",
"sync.running": "同步中…",
"sync.done": "同步完成",
"sync.configSaved": "同步配置已保存",
"sync.lastSync": "上次同步:{{time}}{{status}}",
"sync.conflictTitle": "同步冲突",
"sync.conflictDesc": "本地与远程均有更新,请选择保留哪一侧的数据。",
"sync.useRemote": "使用远程",
"sync.useLocal": "使用本地",
"extensions.title": "扩展",
"extensions.desc": "扩展 API 骨架(不执行第三方代码)",
"extensions.devMode": "开发者模式(仅校验 manifest",
"extensions.stubHint": "完整插件市场将在后续版本提供。",
"update.available": "新版本 {{version}} 可用",
"update.download": "下载更新",
"update.downloading": "下载中 {{percent}}%",
"update.install": "重启并安装",
"crash.title": "检测到异常退出",
"crash.desc": "上次使用时应用可能异常退出。可发送本地日志报告(当前为 stub)。",
"crash.send": "发送报告",
"crash.dismiss": "忽略",
"ai.settings.builtinSkills": "内置 Skill",
"ai.settings.skillContinue": "续写助手",
"ai.settings.skillPolish": "润色助手",
"ai.settings.skillProofread": "纠错助手",
"ai.settings.mcpConnector": "MCP 连接器 URL",
"ai.settings.mcpTest": "测试连接"
}
+23 -1
View File
@@ -7,8 +7,12 @@ import schemaV5 from './schema-v5.sql?raw'
import schemaV6 from './schema-v6.sql?raw'
import schemaV7 from './schema-v7.sql?raw'
import schemaV8 from './schema-v8.sql?raw'
import schemaV9 from './schema-v9.sql?raw'
const CURRENT_VERSION = 8
import schemaV10 from './schema-v10.sql?raw'
import schemaV11 from './schema-v11.sql?raw'
const CURRENT_VERSION = 11
function tryAlter(db: SqliteDb, sql: string): void {
try {
@@ -88,5 +92,23 @@ export function migrate(db: SqliteDb): void {
if (current < 8) {
db.exec(schemaV8)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(8, 'P5.3 P6.1 goals injection')
current = 8
}
if (current < 9) {
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN published_at TEXT DEFAULT NULL')
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN word_count_threshold INTEGER DEFAULT NULL')
db.exec(schemaV9)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(9, 'W1 foundation')
}
if (current < 10) {
db.exec(schemaV10)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(10, 'W4 timeline')
}
if (current < 11) {
db.exec(schemaV11)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(11, 'W5 undo stack')
}
}
@@ -0,0 +1,44 @@
import type { ChapterTag } from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): ChapterTag {
return {
chapterId: row.chapter_id as string,
tag: row.tag as string,
color: (row.color as string) ?? ''
}
}
export class ChapterTagRepository {
constructor(private db: SqliteDb) {}
list(chapterId: string): ChapterTag[] {
return (
this.db
.prepare('SELECT * FROM chapter_tags WHERE chapter_id = ? ORDER BY tag')
.all(chapterId) as Record<string, unknown>[]
).map(mapRow)
}
listAll(): ChapterTag[] {
return (
this.db.prepare('SELECT * FROM chapter_tags ORDER BY chapter_id, tag').all() as Record<
string,
unknown
>[]
).map(mapRow)
}
set(chapterId: string, tag: string, color = ''): void {
this.db
.prepare(
`INSERT INTO chapter_tags (chapter_id, tag, color) VALUES (?, ?, ?)
ON CONFLICT(chapter_id, tag) DO UPDATE SET color = excluded.color`
)
.run(chapterId, tag, color)
}
remove(chapterId: string, tag: string): void {
this.db.prepare('DELETE FROM chapter_tags WHERE chapter_id = ? AND tag = ?').run(chapterId, tag)
}
}
+28 -3
View File
@@ -13,7 +13,12 @@ function mapRow(row: Record<string, unknown>): Chapter {
sortOrder: row.sort_order as number,
cursorOffset: (row.cursor_offset as number) ?? 0,
origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin,
publishStatus: ((row.publish_status as PublishStatus) ?? 'draft') as PublishStatus
publishStatus: ((row.publish_status as PublishStatus) ?? 'draft') as PublishStatus,
povCharacterId: (row.pov_character_id as string) || null,
summary: (row.summary as string) ?? '',
storyTime: (row.story_time as string) ?? null,
publishedAt: (row.published_at as string) ?? null,
wordCountThreshold: (row.word_count_threshold as number) ?? null
}
}
@@ -66,6 +71,11 @@ export class ChapterRepository {
status: ChapterStatus
cursorOffset: number
publishStatus: PublishStatus
povCharacterId: string | null
summary: string
storyTime: string | null
publishedAt: string | null
wordCountThreshold: number | null
}>
): Chapter {
const existing = this.get(id)
@@ -73,12 +83,16 @@ export class ChapterRepository {
const content = patch.content ?? existing.content
const wordCount = countWords(content)
const povCharacterId =
patch.povCharacterId !== undefined ? patch.povCharacterId : existing.povCharacterId ?? null
this.db
.prepare(
`UPDATE chapters SET
title = ?, content = ?, status = ?, cursor_offset = ?,
publish_status = ?, word_count = ?, updated_at = datetime('now')
publish_status = ?, pov_character_id = ?, word_count = ?,
summary = ?, story_time = ?, published_at = ?, word_count_threshold = ?,
updated_at = datetime('now')
WHERE id = ?`
)
.run(
@@ -87,14 +101,25 @@ export class ChapterRepository {
patch.status ?? existing.status,
patch.cursorOffset ?? existing.cursorOffset,
patch.publishStatus ?? existing.publishStatus ?? 'draft',
povCharacterId,
wordCount,
patch.summary ?? existing.summary ?? '',
patch.storyTime !== undefined ? patch.storyTime : (existing.storyTime ?? null),
patch.publishedAt !== undefined ? patch.publishedAt : (existing.publishedAt ?? null),
patch.wordCountThreshold !== undefined
? patch.wordCountThreshold
: (existing.wordCountThreshold ?? null),
id
)
return this.get(id)!
}
setPublishStatus(id: string, publishStatus: PublishStatus): Chapter {
return this.update(id, { publishStatus })
const patch: Parameters<ChapterRepository['update']>[1] = { publishStatus }
if (publishStatus === 'published') {
patch.publishedAt = new Date().toISOString()
}
return this.update(id, patch)
}
countByPublishStatus(status: PublishStatus): number {
@@ -22,4 +22,19 @@ export class PomodoroDailyRepository {
.get(date, bookId) as { completed_count: number } | undefined
return row?.completed_count ?? 0
}
getTodayStats(
bookId: string,
date = localDateString()
): { completedCount: number; totalWords: number } {
const row = this.db
.prepare(
'SELECT completed_count, total_words FROM pomodoro_daily WHERE date = ? AND book_id = ?'
)
.get(date, bookId) as { completed_count: number; total_words: number } | undefined
return {
completedCount: row?.completed_count ?? 0,
totalWords: row?.total_words ?? 0
}
}
}
+61
View File
@@ -0,0 +1,61 @@
import { randomUUID } from 'crypto'
import type { TimelineEntry } from '../../../shared/timeline'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): TimelineEntry {
return {
id: row.id as string,
kind: 'manual',
title: row.title as string,
storyTime: (row.story_time as string) ?? null,
sortOrder: row.sort_order as number,
notes: (row.notes as string) ?? ''
}
}
export class TimelineRepository {
constructor(private db: SqliteDb) {}
listManual(): TimelineEntry[] {
return (
this.db
.prepare('SELECT * FROM timeline_events ORDER BY sort_order ASC, created_at ASC')
.all() as Record<string, unknown>[]
).map(mapRow)
}
upsert(input: {
id?: string
title: string
storyTime?: string | null
sortOrder?: number
notes?: string
}): TimelineEntry {
const id = input.id ?? randomUUID()
const existing = this.db.prepare('SELECT id FROM timeline_events WHERE id = ?').get(id)
if (existing) {
this.db
.prepare(
`UPDATE timeline_events SET title = ?, story_time = ?, sort_order = ?, notes = ? WHERE id = ?`
)
.run(
input.title,
input.storyTime ?? null,
input.sortOrder ?? 0,
input.notes ?? '',
id
)
} else {
this.db
.prepare(
`INSERT INTO timeline_events (id, title, story_time, sort_order, notes) VALUES (?, ?, ?, ?, ?)`
)
.run(id, input.title, input.storyTime ?? null, input.sortOrder ?? 0, input.notes ?? '')
}
return mapRow(this.db.prepare('SELECT * FROM timeline_events WHERE id = ?').get(id) as Record<string, unknown>)
}
delete(id: string): void {
this.db.prepare('DELETE FROM timeline_events WHERE id = ?').run(id)
}
}
+61
View File
@@ -0,0 +1,61 @@
import type { UndoOperation, UndoStackEntry } from '../../../shared/undo'
import type { SqliteDb } from '../types'
const MAX_ENTRIES = 50
function mapRow(row: Record<string, unknown>): UndoStackEntry {
let operation: UndoOperation
try {
operation = JSON.parse(row.operation as string) as UndoOperation
} catch {
operation = { type: 'doc', content: '' }
}
return {
id: row.id as number,
chapterId: row.chapter_id as string,
operation,
timestamp: row.timestamp as string
}
}
export class UndoStackRepository {
constructor(private db: SqliteDb) {}
push(chapterId: string, operation: UndoOperation): UndoStackEntry {
const json = JSON.stringify(operation)
const result = this.db
.prepare('INSERT INTO undo_stack (chapter_id, operation) VALUES (?, ?)')
.run(chapterId, json)
const id = Number(result.lastInsertRowid)
this.trim(chapterId)
return this.get(id)!
}
list(chapterId: string, limit = MAX_ENTRIES): UndoStackEntry[] {
return (
this.db
.prepare(
'SELECT * FROM undo_stack WHERE chapter_id = ? ORDER BY id DESC LIMIT ?'
)
.all(chapterId, limit) as Record<string, unknown>[]
).map(mapRow)
}
get(id: number): UndoStackEntry | null {
const row = this.db.prepare('SELECT * FROM undo_stack WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined
return row ? mapRow(row) : null
}
private trim(chapterId: string): void {
const rows = this.db
.prepare(
'SELECT id FROM undo_stack WHERE chapter_id = ? ORDER BY id DESC LIMIT -1 OFFSET ?'
)
.all(chapterId, MAX_ENTRIES) as Array<{ id: number }>
for (const row of rows) {
this.db.prepare('DELETE FROM undo_stack WHERE id = ?').run(row.id)
}
}
}
+12 -1
View File
@@ -33,7 +33,18 @@ export class WritingLogRepository {
completed_count INTEGER NOT NULL DEFAULT 0,
total_words INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (date, book_id)
)
);
CREATE TABLE IF NOT EXISTS writing_sessions (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL,
chapter_id TEXT,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
word_delta INTEGER NOT NULL DEFAULT 0,
date TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_book_date ON writing_sessions (book_id, date);
CREATE INDEX IF NOT EXISTS idx_sessions_book_started ON writing_sessions (book_id, started_at)
`)
}
@@ -0,0 +1,83 @@
import type { DatabaseSync } from 'node:sqlite'
export interface WritingSessionRow {
id: string
bookId: string
chapterId?: string
startedAt: string
endedAt: string
wordDelta: number
date: string
}
function mapRow(row: Record<string, unknown>): WritingSessionRow {
return {
id: row.id as string,
bookId: row.book_id as string,
chapterId: (row.chapter_id as string) || undefined,
startedAt: row.started_at as string,
endedAt: row.ended_at as string,
wordDelta: row.word_delta as number,
date: row.date as string
}
}
export class WritingSessionRepository {
constructor(private db: DatabaseSync) {}
insert(row: {
id: string
bookId: string
chapterId?: string
startedAt: string
endedAt: string
wordDelta: number
date: string
}): void {
this.db
.prepare(
`INSERT INTO writing_sessions (id, book_id, chapter_id, started_at, ended_at, word_delta, date)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(
row.id,
row.bookId,
row.chapterId ?? null,
row.startedAt,
row.endedAt,
row.wordDelta,
row.date
)
}
updateActivity(id: string, endedAt: string, wordDelta: number): void {
this.db
.prepare(
`UPDATE writing_sessions SET ended_at = ?, word_delta = word_delta + ? WHERE id = ?`
)
.run(endedAt, wordDelta, id)
}
getById(id: string): WritingSessionRow | null {
const row = this.db.prepare('SELECT * FROM writing_sessions WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined
return row ? mapRow(row) : null
}
listForBookDate(bookId: string, date: string): WritingSessionRow[] {
const rows = this.db
.prepare('SELECT * FROM writing_sessions WHERE book_id = ? AND date = ? ORDER BY started_at')
.all(bookId, date) as Record<string, unknown>[]
return rows.map(mapRow)
}
listSince(bookId: string, sinceIso: string): WritingSessionRow[] {
const rows = this.db
.prepare(
`SELECT * FROM writing_sessions WHERE book_id = ? AND started_at >= ? ORDER BY started_at`
)
.all(bookId, sinceIso) as Record<string, unknown>[]
return rows.map(mapRow)
}
}
+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS timeline_events (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
story_time TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS undo_stack (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chapter_id TEXT NOT NULL,
operation TEXT NOT NULL,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_undo_stack_chapter ON undo_stack(chapter_id, id DESC);
+7
View File
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS chapter_tags (
chapter_id TEXT NOT NULL,
tag TEXT NOT NULL,
color TEXT DEFAULT '',
PRIMARY KEY (chapter_id, tag),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
+10
View File
@@ -0,0 +1,10 @@
import { registerExportMatrixHandlers } from './ipc/handlers/export-matrix.handler'
import type { BookRegistryService } from './services/book-registry'
import type { GlobalSettingsService } from './services/global-settings'
export function bootstrapExportMatrixHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
registerExportMatrixHandlers(registry, settings)
}
+8
View File
@@ -0,0 +1,8 @@
import type { BookRegistryService } from './services/book-registry'
import { registerImportHandlers } from './ipc/handlers/import.handler'
export { registerImportHandlers }
export function bootstrapImportHandlers(registry: BookRegistryService): void {
registerImportHandlers(registry)
}
+10 -2
View File
@@ -50,8 +50,16 @@ function createWindow(): void { mainWindow = new BrowserWindow({
getNetworkMonitor()?.start(() => mainWindow)
}
app.whenReady().then(() => {
registerIpc()
app.whenReady().then(async () => {
const { books, settings } = registerIpc()
const { bootstrapImportHandlers } = await import('./import-bootstrap.js')
bootstrapImportHandlers(books)
const { bootstrapPackHandlers } = await import('./pack-bootstrap.js')
bootstrapPackHandlers(books, settings)
const { bootstrapExportMatrixHandlers } = await import('./export-matrix-bootstrap.js')
bootstrapExportMatrixHandlers(books, settings)
const { bootstrapSyncHandlers } = await import('./sync-bootstrap.js')
bootstrapSyncHandlers(books, settings)
ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize()
+44 -1
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'crypto'
import { BrowserWindow, ipcMain } from 'electron'
import { writeFileSync } from 'fs'
import { BrowserWindow, dialog, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { AiConfig, AiContextBinding, NamingConflict } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
@@ -19,6 +20,15 @@ import { wrap } from '../result'
const activeChats = new Map<string, AbortController>()
function formatSessionMarkdown(title: string, messages: { role: string; content: string; createdAt: string }[]): string {
const lines = [`# ${title || 'AI 会话'}`, '']
for (const msg of messages) {
const label = msg.role === 'user' ? '用户' : msg.role === 'assistant' ? '助手' : msg.role
lines.push(`## ${label}`, '', msg.content, '', `_${msg.createdAt}_`, '')
}
return lines.join('\n')
}
export function registerAiHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService,
@@ -67,6 +77,39 @@ export function registerAiHandlers(
wrap(() => registry.getAiSessionRepo(bookId).listMessages(sessionId))
)
ipcMain.handle(
IPC.AI_SESSION_EXPORT,
async (
_e,
{
bookId,
sessionId,
format = 'markdown'
}: { bookId: string; sessionId: string; format?: 'markdown' | 'txt' }
) =>
wrap(async () => {
const repo = registry.getAiSessionRepo(bookId)
const session = repo.get(sessionId)
if (!session) throw new Error('AI session not found')
const messages = repo.listMessages(sessionId)
const text =
format === 'markdown'
? formatSessionMarkdown(session.title, messages)
: messages
.map((m) => `[${m.role}] ${m.createdAt}\n${m.content}`)
.join('\n\n---\n\n')
const ext = format === 'markdown' ? 'md' : 'txt'
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: `${session.title || 'ai-session'}.${ext}`,
filters: [{ name: format === 'markdown' ? 'Markdown' : 'Text', extensions: [ext] }]
})
if (canceled || !filePath) return null
writeFileSync(filePath, text, 'utf8')
return filePath
})
)
ipcMain.handle(
IPC.AI_BUILD_CONTEXT,
(_e, { bookId, binding }: { bookId: string; binding: AiContextBinding }) =>
@@ -0,0 +1,21 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { PomodoroDailyRepository } from '../../db/repositories/pomodoro-daily.repo'
import { WritingSessionRepository } from '../../db/repositories/writing-session.repo'
import { WritingAnalyticsService } from '../../services/writing-analytics.service'
import type { BookRegistryService } from '../../services/book-registry'
import type { WritingLogService } from '../../services/writing-log.service'
import { wrap } from '../result'
export function registerAnalyticsHandlers(
registry: BookRegistryService,
writingLogs: WritingLogService,
sessionRepo: WritingSessionRepository,
pomodoroDaily: PomodoroDailyRepository
): void {
const svc = new WritingAnalyticsService(writingLogs, sessionRepo, pomodoroDaily, registry)
ipcMain.handle(IPC.ANALYTICS_SUMMARY, (_e, { bookId }: { bookId: string }) =>
wrap(() => svc.getSummary(bookId))
)
}
+13
View File
@@ -0,0 +1,13 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { ArcService } from '../../services/arc.service'
import type { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerArcHandlers(registry: BookRegistryService): void {
ipcMain.handle(
IPC.ARC_GET_FOR_CHARACTER,
(_e, { bookId, settingId }: { bookId: string; settingId: string }) =>
wrap(() => new ArcService(registry.getDb(bookId)).getForCharacter(settingId))
)
}
+7 -2
View File
@@ -11,6 +11,7 @@ import { scheduleChapterExtraction } from '../../services/knowledge-extraction-r
import { trackerFor } from '../../services/chapter-writing-tracker.service'
import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner'
import type { WritingLogService } from '../../services/writing-log.service'
import type { WritingSessionService } from '../../services/writing-session.service'
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
import { wrap } from '../result'
@@ -38,7 +39,8 @@ export function registerAutoHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService,
aiClient: AiClientService,
writingLogs: WritingLogService
writingLogs: WritingLogService,
writingSessions: WritingSessionService
): void {
ipcMain.handle(IPC.AUTO_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
@@ -217,7 +219,10 @@ export function registerAutoHandlers(
wrap(() => {
const result = serviceFor(registry, bookId, aiClient).finishChapter(flowId, volumeId, title)
const ch = registry.getChapterRepo(bookId).get(result.chapterId)!
trackerFor(registry, bookId, writingLogs).supplementOnFinish(result.chapterId, ch.wordCount)
trackerFor(registry, bookId, writingLogs, writingSessions).supplementOnFinish(
result.chapterId,
ch.wordCount
)
scheduleChapterExtraction(registry, bookId, result.chapterId, aiClient, settings)
return result
})
@@ -0,0 +1,21 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { BookPrefsRepository } from '../../db/repositories/book-prefs.repo'
import { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerBookPrefsHandlers(registry: BookRegistryService): void {
ipcMain.handle(
IPC.BOOK_PREFS_GET,
(_event, { bookId, key }: { bookId: string; key: string }) =>
wrap(() => new BookPrefsRepository(registry.getDb(bookId)).get(key))
)
ipcMain.handle(
IPC.BOOK_PREFS_SET,
(_event, { bookId, key, value }: { bookId: string; key: string; value: string }) =>
wrap(() => {
new BookPrefsRepository(registry.getDb(bookId)).set(key, value)
})
)
}
+37 -16
View File
@@ -1,10 +1,18 @@
import { ipcMain } from 'electron'
import { ipcMain, dialog, app } from 'electron'
import { copyFileSync, mkdirSync } from 'fs'
import { extname, join } from 'path'
import { IPC } from '../../../shared/ipc-channels'
import type { BookMeta, CreateBookParams } from '../../../shared/types'
import type { CreateBookParams, UpdateBookMetaParams } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import type { WritingSessionService } from '../../services/writing-session.service'
import { wrap } from '../result'
export function registerBookHandlers(registry: BookRegistryService): void {
let previousBookId: string | null = null
export function registerBookHandlers(
registry: BookRegistryService,
writingSessions?: WritingSessionService
): void {
ipcMain.handle(IPC.BOOK_LIST, () => wrap(() => registry.list()))
ipcMain.handle(IPC.BOOK_CREATE, (_event, params: CreateBookParams) =>
@@ -18,22 +26,35 @@ export function registerBookHandlers(registry: BookRegistryService): void {
)
ipcMain.handle(IPC.BOOK_OPEN, (_event, { bookId }: { bookId: string }) =>
wrap(() => registry.open(bookId))
wrap(() => {
if (writingSessions && previousBookId && previousBookId !== bookId) {
writingSessions.endActive(previousBookId)
}
previousBookId = bookId
return registry.open(bookId)
})
)
ipcMain.handle(
IPC.BOOK_UPDATE_META,
(
_event,
{
bookId,
...patch
}: {
bookId: string
lastChapterId?: string | null
lastOpenedAt?: string
status?: BookMeta['status']
}
) => wrap(() => registry.updateMeta(bookId, patch))
(_event, { bookId, ...patch }: { bookId: string } & UpdateBookMetaParams) =>
wrap(() => registry.updateMeta(bookId, patch))
)
ipcMain.handle(IPC.BOOK_PICK_COVER, (_event, { bookId }: { bookId: string }) =>
wrap(async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
filters: [{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'webp'] }],
properties: ['openFile']
})
if (canceled || !filePaths[0]) return null
const src = filePaths[0]
const ext = extname(src).toLowerCase() || '.jpg'
const coversDir = join(app.getPath('userData'), 'covers')
mkdirSync(coversDir, { recursive: true })
const dest = join(coversDir, `${bookId}${ext}`)
copyFileSync(src, dest)
return registry.updateMeta(bookId, { coverPath: dest })
})
)
}
+57 -4
View File
@@ -4,12 +4,14 @@ import type { ChapterStatus, PublishStatus } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { trackerFor } from '../../services/chapter-writing-tracker.service'
import type { WritingLogService } from '../../services/writing-log.service'
import type { WritingSessionService } from '../../services/writing-session.service'
import { ftsSync } from '../../services/fts-sync.service'
import { wrap } from '../result'
export function registerChapterHandlers(
registry: BookRegistryService,
writingLogs: WritingLogService
writingLogs: WritingLogService,
writingSessions: WritingSessionService
): void {
ipcMain.handle(
IPC.VOLUME_CREATE,
@@ -72,7 +74,13 @@ export function registerChapterHandlers(
title,
content,
status,
cursorOffset
cursorOffset,
povCharacterId,
summary,
storyTime,
wordCountThreshold,
publishStatus,
publishedAt
}: {
bookId: string
chapterId: string
@@ -80,6 +88,12 @@ export function registerChapterHandlers(
content?: string
status?: ChapterStatus
cursorOffset?: number
povCharacterId?: string | null
summary?: string
storyTime?: string | null
wordCountThreshold?: number | null
publishStatus?: PublishStatus
publishedAt?: string | null
}
) =>
wrap(() => {
@@ -87,7 +101,13 @@ export function registerChapterHandlers(
title,
content,
status,
cursorOffset
cursorOffset,
povCharacterId,
summary,
storyTime,
wordCountThreshold,
publishStatus,
publishedAt
})
ftsSync.upsert(
registry.getDb(bookId),
@@ -96,7 +116,10 @@ export function registerChapterHandlers(
chapter.title,
chapter.content
)
trackerFor(registry, bookId, writingLogs).recordDelta(chapterId, chapter.wordCount)
trackerFor(registry, bookId, writingLogs, writingSessions).recordDelta(
chapterId,
chapter.wordCount
)
registry.updateMeta(bookId, { lastChapterId: chapterId })
return chapter
})
@@ -138,4 +161,34 @@ export function registerChapterHandlers(
{ bookId, chapterId, publishStatus }: { bookId: string; chapterId: string; publishStatus: PublishStatus }
) => wrap(() => registry.getChapterRepo(bookId).setPublishStatus(chapterId, publishStatus))
)
ipcMain.handle(
IPC.CHAPTER_TAG_LIST,
(_event, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => registry.getChapterTagRepo(bookId).list(chapterId))
)
ipcMain.handle(
IPC.CHAPTER_TAG_SET,
(
_event,
{
bookId,
chapterId,
tag,
color
}: { bookId: string; chapterId: string; tag: string; color?: string }
) =>
wrap(() => {
registry.getChapterTagRepo(bookId).set(chapterId, tag, color ?? '')
})
)
ipcMain.handle(
IPC.CHAPTER_TAG_REMOVE,
(_event, { bookId, chapterId, tag }: { bookId: string; chapterId: string; tag: string }) =>
wrap(() => {
registry.getChapterTagRepo(bookId).remove(chapterId, tag)
})
)
}
@@ -0,0 +1,21 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { ComplianceService } from '../../services/compliance.service'
import type { GlobalSettingsService } from '../../services/global-settings'
import { wrap } from '../result'
export function registerComplianceHandlers(settings: GlobalSettingsService): void {
const svc = new ComplianceService(settings)
ipcMain.handle(IPC.COMPLIANCE_GET_WORDS, () => wrap(() => svc.getWords()))
ipcMain.handle(IPC.COMPLIANCE_SET_WORDS, (_e, { words }: { words: string[] }) =>
wrap(() => {
svc.setWords(words)
})
)
ipcMain.handle(IPC.COMPLIANCE_CHECK_TEXT, (_e, { text }: { text: string }) =>
wrap(() => svc.scanText(text))
)
}
@@ -0,0 +1,18 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { ExportMatrixParams } from '../../../shared/export-matrix'
import { ExportMatrixService } from '../../services/export-matrix.service'
import type { BookRegistryService } from '../../services/book-registry'
import type { GlobalSettingsService } from '../../services/global-settings'
import { wrap } from '../result'
export function registerExportMatrixHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
const svc = new ExportMatrixService(registry, settings)
ipcMain.handle(IPC.EXPORT_MATRIX_EXPORT, (_e, params: ExportMatrixParams) =>
wrap(() => svc.pickAndExport(params))
)
}
+38
View File
@@ -0,0 +1,38 @@
import { BrowserWindow, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { ImportExecuteParams, ImportSplitMode } from '../../../shared/types'
import { ExportService } from '../../services/export.service'
import type { BookRegistryService } from '../../services/book-registry'
import type { GlobalSettingsService } from '../../services/global-settings'
import { wrap } from '../result'
export function registerExportHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
const svc = new ExportService(registry, settings)
ipcMain.handle(
IPC.EXPORT_FORMAT_CHAPTER,
(
_e,
{
bookId,
chapterId,
presetId
}: { bookId: string; chapterId: string; presetId: string }
) => wrap(() => svc.formatChapter(bookId, chapterId, presetId))
)
ipcMain.handle(IPC.EXPORT_COPY_CLIPBOARD, (_e, { text }: { text: string }) =>
wrap(() => {
svc.copyToClipboard(text)
})
)
ipcMain.handle(
IPC.EXPORT_SAVE_TXT,
(_e, { text, defaultName }: { text: string; defaultName: string }) =>
wrap(() => svc.saveTxt(text, defaultName))
)
}
+51
View File
@@ -0,0 +1,51 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { CharacterRelationship } from '../../../shared/types'
import { CharacterGraphService } from '../../services/character-graph.service'
import type { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerGraphHandlers(registry: BookRegistryService): void {
const svc = () => new CharacterGraphService(registry)
ipcMain.handle(
IPC.GRAPH_GET_DATA,
(_e, { bookId, filter }: { bookId: string; filter?: 'all' | 'connected' }) =>
wrap(() => svc().getData(bookId, filter ?? 'all'))
)
ipcMain.handle(
IPC.GRAPH_UPSERT_RELATIONSHIP,
(
_e,
{
bookId,
sourceId,
relationship
}: {
bookId: string
sourceId: string
relationship: Partial<CharacterRelationship> & {
targetId: string
label: string
intimacy: number
}
}
) => wrap(() => svc().upsertRelationship(bookId, sourceId, relationship))
)
ipcMain.handle(
IPC.GRAPH_DELETE_RELATIONSHIP,
(
_e,
{
bookId,
sourceId,
relationshipId
}: { bookId: string; sourceId: string; relationshipId: string }
) =>
wrap(() => {
svc().deleteRelationship(bookId, sourceId, relationshipId)
})
)
}
+35
View File
@@ -0,0 +1,35 @@
import { BrowserWindow, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { ImportExecuteParams, ImportSplitMode } from '../../../shared/types'
import { ImportService } from '../../services/import.service'
import type { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerImportHandlers(registry: BookRegistryService): void {
const svc = new ImportService(registry)
ipcMain.handle(IPC.IMPORT_PICK_FILE, () => wrap(() => svc.pickFile()))
ipcMain.handle(
IPC.IMPORT_PREVIEW,
(
_e,
{
filePath,
splitMode,
customRegex
}: { filePath: string; splitMode: ImportSplitMode; customRegex?: string }
) => wrap(() => svc.preview(filePath, splitMode, customRegex))
)
ipcMain.handle(IPC.IMPORT_EXECUTE, (_e, params: ImportExecuteParams) =>
wrap(async () => {
const bookId = await svc.execute(params, (current, total) => {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(IPC.IMPORT_PROGRESS, { current, total })
}
})
return bookId
})
)
}
+7 -2
View File
@@ -11,6 +11,7 @@ import { scheduleChapterExtraction } from '../../services/knowledge-extraction-r
import { trackerFor } from '../../services/chapter-writing-tracker.service'
import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner'
import type { WritingLogService } from '../../services/writing-log.service'
import type { WritingSessionService } from '../../services/writing-session.service'
import { wrap } from '../result'
const activeInteractive = new Map<string, AbortController>()
@@ -29,7 +30,8 @@ export function registerInteractiveHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService,
aiClient: AiClientService,
writingLogs: WritingLogService
writingLogs: WritingLogService,
writingSessions: WritingSessionService
): void {
ipcMain.handle(IPC.INTERACTIVE_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
wrap(() => checkInteractiveGate(registry.getDb(bookId)))
@@ -208,7 +210,10 @@ export function registerInteractiveHandlers(
wrap(() => {
const result = serviceFor(registry, bookId, aiClient).finishChapter(flowId, volumeId, title)
const ch = registry.getChapterRepo(bookId).get(result.chapterId)!
trackerFor(registry, bookId, writingLogs).supplementOnFinish(result.chapterId, ch.wordCount)
trackerFor(registry, bookId, writingLogs, writingSessions).supplementOnFinish(
result.chapterId,
ch.wordCount
)
scheduleChapterExtraction(registry, bookId, result.chapterId, aiClient, settings)
return result
})
+33
View File
@@ -0,0 +1,33 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { LogService } from '../../services/log.service'
import { extensionRegistry } from '../../services/extension-registry'
import { wrap } from '../result'
export function registerLogHandlers(logs: LogService): void {
ipcMain.handle(IPC.LOG_HAD_CRASH, () => wrap(() => logs.hadCrashOnLastRun()))
ipcMain.handle(IPC.LOG_CLEAR_CRASH, () =>
wrap(() => {
logs.clearCrashFlag()
})
)
ipcMain.handle(IPC.LOG_SEND_REPORT, (_e, { note }: { note?: string }) =>
wrap(() => logs.sendReportStub(note))
)
ipcMain.handle(IPC.LOG_WRITE_ERROR, (_e, { message }: { message: string }) =>
wrap(() => {
logs.logManualError(message)
})
)
}
export function registerExtensionHandlers(): void {
ipcMain.handle(IPC.EXTENSION_LIST, () => wrap(() => extensionRegistry.list()))
ipcMain.handle(IPC.EXTENSION_VALIDATE, (_e, { manifest }: { manifest: unknown }) =>
wrap(() => extensionRegistry.validateManifest(manifest))
)
ipcMain.handle(IPC.MCP_TEST_CONNECTION, (_e, { url }: { url: string }) =>
wrap(() => ({ ok: true, message: `MCP stub: ${url || 'no url'}` }))
)
}
+103
View File
@@ -0,0 +1,103 @@
import { dialog, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { PackImportStrategy } from '../../../shared/novel-pack'
import { PackService } from '../../services/pack.service'
import type { BookRegistryService } from '../../services/book-registry'
import type { GlobalSettingsService } from '../../services/global-settings'
import { wrap } from '../result'
let packService: PackService | null = null
function svc(registry: BookRegistryService, settings: GlobalSettingsService): PackService {
if (!packService) {
packService = new PackService(registry, settings, registry.getUserDataDir())
}
return packService
}
export function registerPackHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
const service = () => svc(registry, settings)
ipcMain.handle(
IPC.PACK_EXPORT_BOOK,
(_e, { bookId, destPath }: { bookId: string; destPath: string }) =>
wrap(() => service().exportBook(bookId, destPath))
)
ipcMain.handle(IPC.PACK_IMPORT_BOOK, (_e, { filePath }: { filePath: string }) =>
wrap(() => service().importBook(filePath))
)
ipcMain.handle(IPC.PACK_EXPORT_ALL, (_e, { destPath }: { destPath: string }) =>
wrap(() => service().exportAll(destPath))
)
ipcMain.handle(
IPC.PACK_IMPORT_ALL,
(_e, { filePath, strategy }: { filePath: string; strategy: PackImportStrategy }) =>
wrap(() => service().importAll(filePath, strategy))
)
ipcMain.handle(IPC.PACK_ESTIMATE_EXPORT_ALL, () =>
wrap(() => ({
bytes: service().estimateExportAllBytes(),
needsConfirm: service().needsExportAllConfirm()
}))
)
ipcMain.handle(IPC.PACK_NEEDS_CONFIRM, () =>
wrap(() => service().needsExportAllConfirm())
)
ipcMain.handle(IPC.PACK_CANCEL, () =>
wrap(() => {
service().cancel()
})
)
ipcMain.handle(IPC.PACK_PICK_FILE, (_e, { mode }: { mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import' }) =>
wrap(async () => {
if (process.env.BILIN_E2E === '1') {
if (
(mode === 'save-novel' || mode === 'save-all') &&
process.env.BILIN_E2E_PACK_SAVE
) {
return process.env.BILIN_E2E_PACK_SAVE
}
if (
(mode === 'import' || mode === 'novel' || mode === 'novel-all') &&
process.env.BILIN_E2E_PACK_IMPORT_FILE
) {
return process.env.BILIN_E2E_PACK_IMPORT_FILE
}
if (mode === 'import' && process.env.BILIN_E2E_IMPORT_FILE) {
return process.env.BILIN_E2E_IMPORT_FILE
}
}
if (mode === 'save-novel' || mode === 'save-all') {
const ext = mode === 'save-all' ? 'novel-all' : 'novel'
const { canceled, filePath } = await dialog.showSaveDialog({
filters: [{ name: 'Bilin Pack', extensions: [ext] }],
defaultPath: mode === 'save-all' ? 'bilin-backup.novel-all' : 'book.novel'
})
if (canceled || !filePath) return null
return filePath
}
const extensions =
mode === 'import'
? ['txt', 'md', 'docx', 'novel', 'novel-all']
: mode === 'novel-all'
? ['novel-all']
: ['novel', 'novel-all']
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Bilin Pack', extensions }]
})
if (canceled || filePaths.length === 0) return null
return filePaths[0]
})
)
}
+41
View File
@@ -0,0 +1,41 @@
import { dialog, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { SyncConfigureInput, SyncRunInput } from '../../../shared/sync'
import { SyncService } from '../../services/sync.service'
import type { BookRegistryService } from '../../services/book-registry'
import type { GlobalSettingsService } from '../../services/global-settings'
import { wrap } from '../result'
let syncService: SyncService | null = null
function svc(registry: BookRegistryService, settings: GlobalSettingsService): SyncService {
if (!syncService) {
syncService = new SyncService(registry, settings, registry.getUserDataDir())
}
return syncService
}
export function registerSyncHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
const service = () => svc(registry, settings)
ipcMain.handle(IPC.SYNC_STATUS, () => wrap(() => service().getStatus()))
ipcMain.handle(IPC.SYNC_CONFIGURE, (_e, input: SyncConfigureInput) =>
wrap(() => service().configure(input))
)
ipcMain.handle(IPC.SYNC_RUN, (_e, input: SyncRunInput) =>
wrap(() => service().run(input.password, input.conflictResolution))
)
ipcMain.handle(IPC.SYNC_PICK_FOLDER, async () =>
wrap(async () => {
const result = await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] })
if (result.canceled || !result.filePaths[0]) return null
return result.filePaths[0]
})
)
}
+28
View File
@@ -0,0 +1,28 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { TimelineUpsertManualInput } from '../../../shared/timeline'
import { TimelineService } from '../../services/timeline.service'
import type { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerTimelineHandlers(registry: BookRegistryService): void {
ipcMain.handle(IPC.TIMELINE_LIST, (_e, { bookId }: { bookId: string }) =>
wrap(() => new TimelineService(registry.getDb(bookId)).list())
)
ipcMain.handle(
IPC.TIMELINE_UPSERT,
(_e, { bookId, input }: { bookId: string; input: TimelineUpsertManualInput }) =>
wrap(() => new TimelineService(registry.getDb(bookId)).upsertManual(input))
)
ipcMain.handle(IPC.TIMELINE_DELETE, (_e, { bookId, id }: { bookId: string; id: string }) =>
wrap(() => {
new TimelineService(registry.getDb(bookId)).deleteManual(id)
})
)
ipcMain.handle(IPC.TIMELINE_CONFLICTS, (_e, { bookId }: { bookId: string }) =>
wrap(() => new TimelineService(registry.getDb(bookId)).detectConflicts())
)
}
+36
View File
@@ -0,0 +1,36 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { UndoOperation } from '../../../shared/undo'
import { UndoStackService } from '../../services/undo-stack.service'
import type { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerUndoHandlers(registry: BookRegistryService): void {
ipcMain.handle(
IPC.UNDO_PUSH,
(
_e,
{
bookId,
chapterId,
operation
}: { bookId: string; chapterId: string; operation: UndoOperation }
) => wrap(() => new UndoStackService(registry.getDb(bookId)).push(chapterId, operation))
)
ipcMain.handle(
IPC.UNDO_LIST,
(_e, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => new UndoStackService(registry.getDb(bookId)).list(chapterId))
)
ipcMain.handle(
IPC.UNDO_APPLY,
(_e, { bookId, entryId }: { bookId: string; entryId: number }) =>
wrap(() => {
const op = new UndoStackService(registry.getDb(bookId)).apply(entryId)
if (!op) throw new Error('Undo entry not found')
return op
})
)
}
+21
View File
@@ -0,0 +1,21 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { UpdateService } from '../../services/update.service'
import { wrap } from '../result'
let updateService: UpdateService | null = null
function service(): UpdateService {
if (!updateService) updateService = new UpdateService()
return updateService
}
export function registerUpdateHandlers(): void {
ipcMain.handle(IPC.UPDATE_CHECK, () => wrap(() => service().check()))
ipcMain.handle(IPC.UPDATE_DOWNLOAD, () => wrap(() => service().download()))
ipcMain.handle(IPC.UPDATE_INSTALL, () =>
wrap(() => {
service().install()
})
)
}
+43 -6
View File
@@ -10,8 +10,11 @@ import { WritingLogService } from '../services/writing-log.service'
import { GoalNotificationService } from '../services/goal-notification.service'
import { AchievementService } from '../services/achievement.service'
import { PomodoroService } from '../services/pomodoro.service'
import { WritingSessionRepository } from '../db/repositories/writing-session.repo'
import { WritingSessionService } from '../services/writing-session.service'
import { registerSettingsHandlers } from './handlers/settings.handler'
import { registerBookHandlers } from './handlers/book.handler'
import { registerBookPrefsHandlers } from './handlers/book-prefs.handler'
import { registerChapterHandlers } from './handlers/chapter.handler'
import { registerShortcutHandlers } from './handlers/shortcut.handler'
import { registerOutlineHandlers } from './handlers/outline.handler'
@@ -29,7 +32,17 @@ import { registerCockpitHandlers } from './handlers/cockpit.handler'
import { registerWritingHandlers } from './handlers/writing.handler'
import { registerPomodoroHandlers } from './handlers/pomodoro.handler'
import { registerAchievementHandlers } from './handlers/achievement.handler'
import { registerGraphHandlers } from './handlers/graph.handler'
import { registerAnalyticsHandlers } from './handlers/analytics.handler'
import { registerBridgeHandlers } from './handlers/bridge.handler'
import { registerExportHandlers } from './handlers/export.handler'
import { registerTimelineHandlers } from './handlers/timeline.handler'
import { registerArcHandlers } from './handlers/arc.handler'
import { registerComplianceHandlers } from './handlers/compliance.handler'
import { registerUndoHandlers } from './handlers/undo.handler'
import { registerLogHandlers, registerExtensionHandlers } from './handlers/log.handler'
import { registerUpdateHandlers } from './handlers/update.handler'
import { LogService } from '../services/log.service'
import { AiClientService } from '../services/ai-client.service'
import { NetworkMonitorService } from '../services/network-monitor.service'
@@ -37,15 +50,28 @@ let shortcutManager: ShortcutManager | null = null
let snapshotService: SnapshotService | null = null
let networkMonitor: NetworkMonitorService | null = null
export function registerIpc(): { settings: GlobalSettingsService; books: BookRegistryService; shortcuts: ShortcutManager } {
export function registerIpc(): {
settings: GlobalSettingsService
books: BookRegistryService
shortcuts: ShortcutManager
logs: LogService
} {
const userData = app.getPath('userData')
const settings = new GlobalSettingsService(userData)
const logs = new LogService(userData)
if (!settings.get().onboardingCompleted) {
const locale = app.getLocale().toLowerCase()
const language = locale.startsWith('en') ? 'en' : 'zh-CN'
settings.update({ language })
}
const books = new BookRegistryService(userData)
const writingLogRepo = new WritingLogRepository(userData)
const writingLogs = new WritingLogService(writingLogRepo, settings)
const milestoneRepo = new WritingMilestoneRepository(writingLogRepo.getDb())
const pomodoroDailyRepo = new PomodoroDailyRepository(writingLogRepo.getDb())
const writingSessionRepo = new WritingSessionRepository(writingLogRepo.getDb())
const writingSessions = new WritingSessionService(writingSessionRepo)
const goalNotify = new GoalNotificationService(settings)
const achievementService = new AchievementService(
milestoneRepo,
@@ -61,8 +87,9 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
networkMonitor = new NetworkMonitorService()
registerSettingsHandlers(settings)
registerBookHandlers(books)
registerChapterHandlers(books, writingLogs)
registerBookHandlers(books, writingSessions)
registerBookPrefsHandlers(books)
registerChapterHandlers(books, writingLogs, writingSessions)
registerShortcutHandlers(shortcutManager)
registerOutlineHandlers(books)
registerSettingHandlers(books)
@@ -72,17 +99,27 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
registerSearchHandlers(books, settings)
const aiClient = new AiClientService(() => settings.get().aiConfig)
registerAiHandlers(books, settings, aiClient)
registerInteractiveHandlers(books, settings, aiClient, writingLogs)
registerAutoHandlers(books, settings, aiClient, writingLogs)
registerInteractiveHandlers(books, settings, aiClient, writingLogs, writingSessions)
registerAutoHandlers(books, settings, aiClient, writingLogs, writingSessions)
registerWizardHandlers(books, settings, aiClient)
registerKnowledgeHandlers(books, settings, aiClient)
registerCockpitHandlers(books, settings, writingLogs, achievementService, pomodoroDailyRepo)
registerWritingHandlers(writingLogs)
registerPomodoroHandlers(pomodoro)
registerAchievementHandlers(achievementService)
registerGraphHandlers(books)
registerAnalyticsHandlers(books, writingLogs, writingSessionRepo, pomodoroDailyRepo)
registerBridgeHandlers(books, aiClient)
registerExportHandlers(books, settings)
registerTimelineHandlers(books)
registerArcHandlers(books)
registerComplianceHandlers(settings)
registerUndoHandlers(books)
registerLogHandlers(logs)
registerExtensionHandlers()
registerUpdateHandlers()
return { settings, books, shortcuts: shortcutManager }
return { settings, books, shortcuts: shortcutManager, logs }
}
export function getShortcutManager(): ShortcutManager | null {
+80
View File
@@ -0,0 +1,80 @@
import type { ImportSplitMode } from '../../shared/types'
export interface SplitChapterPart {
title: string
body: string
}
const CH_DI = '\u7b2c'
const CH_ZHANG = '\u7ae0'
const MARKDOWN_HEADING = /^#\s*.+/gm
const CHINESE_CHAPTER = new RegExp(
'^' + CH_DI + '[\\d\\u4e00-\\u9fff]+' + CH_ZHANG + '[^\\n]*',
'gm'
)
const DEFAULT_TITLE = CH_DI + '\u4e00' + CH_ZHANG
function splitByRegex(text: string, regex: RegExp): SplitChapterPart[] {
const matches = [...text.matchAll(regex)]
if (matches.length === 0) return []
const parts: SplitChapterPart[] = []
for (let i = 0; i < matches.length; i++) {
const match = matches[i]!
const title = match[0].replace(/^#\s*/, '').trim()
const start = (match.index ?? 0) + match[0].length
const end = i + 1 < matches.length ? matches[i + 1]!.index! : text.length
const body = text.slice(start, end).trim()
parts.push({ title: title.slice(0, 50), body })
}
return parts
}
function splitByParagraph(text: string): SplitChapterPart[] {
const blocks = text.split(/\n\s*\n/).filter((b) => b.trim())
return blocks.map((block) => {
const lines = block.trim().split('\n')
const title = (lines[0] ?? 'Untitled').trim().slice(0, 50)
const body = (lines.slice(1).join('\n').trim() || lines[0]) ?? ''
return { title, body: body || title }
})
}
export function splitPlainText(
text: string,
mode: ImportSplitMode,
customRegex?: string
): SplitChapterPart[] {
const normalized = text.replace(/\r\n/g, '\n').trim()
if (!normalized) return []
if (mode === 'paragraph') {
return splitByParagraph(normalized)
}
if (mode === 'regex') {
if (!customRegex?.trim()) return []
try {
const regex = new RegExp(customRegex, 'gm')
return splitByRegex(normalized, regex)
} catch {
return []
}
}
let parts = splitByRegex(normalized, MARKDOWN_HEADING)
if (parts.length === 0) parts = splitByRegex(normalized, CHINESE_CHAPTER)
if (parts.length === 0) {
return [{ title: DEFAULT_TITLE, body: normalized }]
}
const firstMatch = normalized.match(MARKDOWN_HEADING) ?? normalized.match(CHINESE_CHAPTER)
if (firstMatch && (firstMatch.index ?? 0) > 0) {
const preamble = normalized.slice(0, firstMatch.index).trim()
if (preamble && parts[0]) {
parts[0].body = preamble + '\n\n' + parts[0].body
}
}
return parts
}
+111
View File
@@ -0,0 +1,111 @@
import { stripHtml } from '../services/word-count'
import type { Chapter } from '../../shared/types'
import type { ExportMatrixOptions } from '../../shared/export-matrix'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { VolumeRepository } from '../db/repositories/volume.repo'
import { BookmarkRepository } from '../db/repositories/bookmark.repo'
import { SnapshotRepository } from '../db/repositories/snapshot.repo'
import type { SqliteDb } from '../db/types'
export interface ExportChapterBlock {
id: string
title: string
volumeName: string
paragraphs: string[]
annotations: string[]
hasAiContent: boolean
}
export function htmlToPlainParagraphs(html: string): string[] {
const trimmed = html.trim()
if (!trimmed) return []
const pMatches = trimmed.match(/<p[^>]*>([\s\S]*?)<\/p>/gi)
if (pMatches && pMatches.length > 0) {
return pMatches.map((p) => stripHtml(p).trim()).filter(Boolean)
}
const plain = stripHtml(trimmed)
if (!plain.trim()) return []
return plain
.split(/\n{2,}/)
.map((s) => s.trim())
.filter(Boolean)
}
export function htmlToMarkdownParagraphs(html: string): string[] {
return htmlToPlainParagraphs(html)
}
export function resolveExportChapters(
db: SqliteDb,
scope: 'chapter' | 'volume' | 'book',
scopeId?: string
): Chapter[] {
const chapterRepo = new ChapterRepository(db)
const all = chapterRepo.list().sort((a, b) => a.sortOrder - b.sortOrder)
if (scope === 'chapter') {
if (!scopeId) throw new Error('Chapter scope requires scopeId')
const ch = chapterRepo.get(scopeId)
return ch ? [ch] : []
}
if (scope === 'volume') {
if (!scopeId) throw new Error('Volume scope requires scopeId')
return all.filter((c) => c.volumeId === scopeId)
}
return all
}
export function buildExportBlocks(
db: SqliteDb,
chapters: Chapter[],
options: ExportMatrixOptions = {}
): ExportChapterBlock[] {
const volumeRepo = new VolumeRepository(db)
const bookmarkRepo = new BookmarkRepository(db)
const snapshotRepo = new SnapshotRepository(db)
const volumes = new Map(volumeRepo.list().map((v) => [v.id, v.name]))
return chapters.map((ch) => {
const annotations =
options.includeAnnotations === true
? bookmarkRepo.listByChapter(ch.id).map((b) => `[${b.landmarkType}] ${b.label}`)
: []
const hasAiContent =
options.markAiContent === true && snapshotRepo.list(ch.id).some((s) => s.type === 'ai')
return {
id: ch.id,
title: ch.title,
volumeName: (ch.volumeId && volumes.get(ch.volumeId)) || '',
paragraphs: htmlToPlainParagraphs(ch.content),
annotations,
hasAiContent
}
})
}
export function buildTxtExport(blocks: ExportChapterBlock[]): string {
return blocks
.map((block) => {
const lines: string[] = [`# ${block.title}`]
if (block.hasAiContent) lines.push('[AI生成内容]', '')
lines.push(...block.paragraphs)
if (block.annotations.length > 0) {
lines.push('', '--- 批注 ---', ...block.annotations.map((a) => `- ${a}`))
}
return lines.join('\n')
})
.join('\n\n')
}
export function buildMdExport(blocks: ExportChapterBlock[]): string {
return blocks
.map((block) => {
const lines: string[] = [`# ${block.title}`]
if (block.hasAiContent) lines.push('', '> *AI 生成内容*', '')
lines.push(...block.paragraphs)
if (block.annotations.length > 0) {
lines.push('', '## 批注', ...block.annotations.map((a) => `- ${a}`))
}
return lines.join('\n')
})
.join('\n\n')
}
+28
View File
@@ -0,0 +1,28 @@
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 = input.contentHtml
.replace(/<\/p>/gi, '\n')
.replace(/<br\s*\/?>/gi, '\n')
body = stripHtml(body)
if (!input.preset.includeAuthorsNote) {
body = body.split(/\n\u2014\u2014\n/)[0] ?? body
}
const paragraphs = body.split(/\n+/).filter((p) => p.trim())
const indented = input.preset.indentParagraphs
? paragraphs.map((p) => '\u3000\u3000' + 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)}`
}
+19
View File
@@ -0,0 +1,19 @@
import type { SubmissionPreset } from '../../shared/types'
export const MAIN_BUILTIN_SUBMISSION_PRESET: SubmissionPreset = {
id: 'builtin-webnovel',
name: 'builtin-webnovel',
titleFormat: '# \u7b2c{chapterNumber}\u7ae0 {chapterTitle}',
indentParagraphs: true,
includeAuthorsNote: true,
paragraphGap: 'double'
}
export function mergeSubmissionPresetsForMain(
custom: SubmissionPreset[] = []
): SubmissionPreset[] {
return [
MAIN_BUILTIN_SUBMISSION_PRESET,
...custom.filter((p) => p.id !== 'builtin-webnovel')
]
}
+10
View File
@@ -0,0 +1,10 @@
import { registerPackHandlers } from './ipc/handlers/pack.handler'
import type { BookRegistryService } from './services/book-registry'
import type { GlobalSettingsService } from './services/global-settings'
export function bootstrapPackHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
registerPackHandlers(registry, settings)
}
+47
View File
@@ -0,0 +1,47 @@
import type { CharacterArcNode } from '../../shared/arc'
import { KnowledgeRepository } from '../db/repositories/knowledge.repo'
import { SettingRepository } from '../db/repositories/setting.repo'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import type { SqliteDb } from '../db/types'
export class ArcService {
constructor(private db: SqliteDb) {}
getForCharacter(settingId: string): CharacterArcNode[] {
const setting = new SettingRepository(this.db).get(settingId)
if (!setting || setting.type !== 'character') return []
const knowledge = new KnowledgeRepository(this.db)
.list()
.filter((k) => k.type === 'character' && k.title.includes(setting.name))
const chapterRepo = new ChapterRepository(this.db)
const chapters = new Map(chapterRepo.list().map((c) => [c.id, c]))
const nodes: CharacterArcNode[] = knowledge
.map((k) => {
const chapterId = k.lastMentionChapterId ?? k.sourceChapterId
const chapter = chapterId ? chapters.get(chapterId) : undefined
return {
id: k.id,
title: k.title,
content: k.content,
chapterId: chapterId ?? null,
chapterTitle: chapter?.title ?? null,
updatedAt: k.updatedAt
}
})
.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt))
if (nodes.length === 0 && setting.description.trim()) {
nodes.push({
id: `setting:${setting.id}`,
title: '角色设定',
content: setting.description.replace(/<[^>]+>/g, '').slice(0, 200),
chapterId: null,
chapterTitle: null,
updatedAt: setting.updatedAt
})
}
return nodes
}
}
+50 -3
View File
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'fs'
import { join } from 'path'
import { randomUUID } from 'crypto'
import { openBookDb } from '../db/connection'
@@ -11,7 +11,8 @@ import { AiSessionRepository } from '../db/repositories/ai-session.repo'
import { InteractiveRepository } from '../db/repositories/interactive.repo'
import { SnapshotRepository } from '../db/repositories/snapshot.repo'
import { BookmarkRepository } from '../db/repositories/bookmark.repo'
import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume } from '../../shared/types'
import { ChapterTagRepository } from '../db/repositories/chapter-tag.repo'
import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume, UpdateBookMetaParams } from '../../shared/types'
interface RegistryFile {
books: BookMeta[]
@@ -49,6 +50,10 @@ export class BookRegistryService {
return this.readRegistry().books.find((b) => b.id === bookId) ?? null
}
getUserDataDir(): string {
return this.userDataDir
}
private dbPath(bookId: string): string {
return join(this.booksDir, `${bookId}.sqlite`)
}
@@ -90,7 +95,45 @@ export class BookRegistryService {
this.writeRegistry(registry)
}
updateMeta(bookId: string, patch: Partial<Pick<BookMeta, 'lastOpenedAt' | 'lastChapterId' | 'status'>>): BookMeta {
private ensureUniqueName(name: string): string {
const names = new Set(this.list().map((b) => b.name))
if (!names.has(name)) return name
let i = 2
while (names.has(`${name} (${i})`)) i += 1
return `${name} (${i})`
}
importFromPack(meta: BookMeta, sqliteSourcePath: string, coverPath: string | null): BookMeta {
const id = randomUUID()
const now = new Date().toISOString()
const imported: BookMeta = {
...meta,
id,
name: this.ensureUniqueName(meta.name),
dbPath: join('books', `${id}.sqlite`),
coverPath: coverPath ?? meta.coverPath ?? null,
lastOpenedAt: now,
createdAt: meta.createdAt ?? now
}
copyFileSync(sqliteSourcePath, this.dbPath(id))
const registry = this.readRegistry()
registry.books.unshift(imported)
this.writeRegistry(registry)
return imported
}
replaceFromPack(bookId: string, meta: BookMeta, sqliteSourcePath: string): BookMeta {
copyFileSync(sqliteSourcePath, this.dbPath(bookId))
return this.updateMeta(bookId, {
name: meta.name,
category: meta.category,
targetWordCount: meta.targetWordCount,
synopsis: meta.synopsis,
status: meta.status
})
}
updateMeta(bookId: string, patch: UpdateBookMetaParams): BookMeta {
const registry = this.readRegistry()
const idx = registry.books.findIndex((b) => b.id === bookId)
if (idx === -1) throw new Error('Book not found')
@@ -162,6 +205,10 @@ export class BookRegistryService {
getBookmarkRepo(bookId: string): BookmarkRepository {
return new BookmarkRepository(this.getDb(bookId))
}
getChapterTagRepo(bookId: string): ChapterTagRepository {
return new ChapterTagRepository(this.getDb(bookId))
}
}
export function aggregateWordCounts(chapters: Chapter[], volumes: Volume[]): {
@@ -2,13 +2,15 @@ import type { SqliteDb } from '../db/types'
import { ChapterWritingSnapshotRepository } from '../db/repositories/chapter-writing-snapshot.repo'
import type { BookRegistryService } from './book-registry'
import type { WritingLogService } from './writing-log.service'
import type { WritingSessionService } from './writing-session.service'
export function trackerFor(
registry: BookRegistryService,
bookId: string,
writingLogs: WritingLogService
writingLogs: WritingLogService,
sessions: WritingSessionService
): ChapterWritingTracker {
return new ChapterWritingTracker(registry.getDb(bookId), bookId, writingLogs)
return new ChapterWritingTracker(registry.getDb(bookId), bookId, writingLogs, sessions)
}
export class ChapterWritingTracker {
@@ -17,7 +19,8 @@ export class ChapterWritingTracker {
constructor(
bookDb: SqliteDb,
private bookId: string,
private writingLogs: WritingLogService
private writingLogs: WritingLogService,
private sessions: WritingSessionService
) {
this.snapRepo = new ChapterWritingSnapshotRepository(bookDb)
}
@@ -26,7 +29,10 @@ export class ChapterWritingTracker {
const snap = this.snapRepo.get(chapterId)
const logged = snap?.loggedWordCount ?? 0
const delta = newWordCount - logged
if (delta !== 0) this.writingLogs.addToday(this.bookId, delta)
if (delta !== 0) {
this.writingLogs.addToday(this.bookId, delta)
this.sessions.recordActivity(this.bookId, chapterId, delta)
}
this.snapRepo.upsert(chapterId, newWordCount, snap?.finishSupplemented ?? false)
}
@@ -34,7 +40,10 @@ export class ChapterWritingTracker {
const snap = this.snapRepo.get(chapterId)
const logged = snap?.loggedWordCount ?? 0
const gap = wordCount - logged
if (gap > 0) this.writingLogs.addToday(this.bookId, gap)
if (gap > 0) {
this.writingLogs.addToday(this.bookId, gap)
this.sessions.recordActivity(this.bookId, chapterId, gap)
}
this.snapRepo.upsert(chapterId, wordCount, true)
}
}
@@ -0,0 +1,140 @@
import { randomUUID } from 'crypto'
import type {
CharacterGraphData,
CharacterGraphEdge,
CharacterRelationship,
SettingEntry
} from '../../shared/types'
import { SettingRepository } from '../db/repositories/setting.repo'
import type { BookRegistryService } from './book-registry'
const MAX_NODES = 100
function parseRelationships(entry: SettingEntry): CharacterRelationship[] {
const raw = entry.properties?.relationships
if (!Array.isArray(raw)) return []
return raw.filter(
(r): r is CharacterRelationship =>
r != null &&
typeof r === 'object' &&
typeof (r as CharacterRelationship).id === 'string' &&
typeof (r as CharacterRelationship).targetId === 'string'
)
}
function dedupeEdges(
raw: Array<{ source: string; target: string; label: string; intimacy: number; id: string }>
): CharacterGraphEdge[] {
const best = new Map<string, CharacterGraphEdge>()
for (const e of raw) {
const key = [e.source, e.target].sort().join('|')
const existing = best.get(key)
if (!existing || e.intimacy > existing.intimacy) {
best.set(key, {
id: e.id,
source: e.source,
target: e.target,
label: e.label,
intimacy: e.intimacy as 1 | 2 | 3 | 4 | 5
})
}
}
return [...best.values()]
}
export class CharacterGraphService {
constructor(private registry: BookRegistryService) {}
getData(bookId: string, filter: 'all' | 'connected' = 'all'): CharacterGraphData {
const repo = new SettingRepository(this.registry.getDb(bookId))
const characters = repo.list('character')
const nodeCount = characters.length
const truncated = nodeCount > MAX_NODES
let nodes = characters.map((c) => ({ id: c.id, label: c.name }))
const rawEdges: Array<{
source: string
target: string
label: string
intimacy: number
id: string
}> = []
for (const c of characters) {
for (const rel of parseRelationships(c)) {
if (!characters.some((x) => x.id === rel.targetId)) continue
rawEdges.push({
source: c.id,
target: rel.targetId,
label: rel.label,
intimacy: rel.intimacy,
id: rel.id
})
}
}
let edges = dedupeEdges(rawEdges)
if (filter === 'connected') {
const connected = new Set<string>()
for (const e of edges) {
connected.add(e.source)
connected.add(e.target)
}
nodes = nodes.filter((n) => connected.has(n.id))
}
if (truncated) {
nodes = nodes.slice(0, MAX_NODES)
const allowed = new Set(nodes.map((n) => n.id))
edges = edges.filter((e) => allowed.has(e.source) && allowed.has(e.target))
}
return { nodes, edges, truncated, nodeCount }
}
upsertRelationship(
bookId: string,
sourceId: string,
input: Partial<CharacterRelationship> & {
targetId: string
label: string
intimacy: number
}
): CharacterRelationship {
const repo = new SettingRepository(this.registry.getDb(bookId))
const source = repo.get(sourceId)
const target = repo.get(input.targetId)
if (!source || source.type !== 'character') throw new Error('Invalid source character')
if (!target || target.type !== 'character') throw new Error('Invalid target character')
if (sourceId === input.targetId) throw new Error('Cannot relate character to itself')
const intimacy = Math.min(5, Math.max(1, Math.round(input.intimacy))) as CharacterRelationship['intimacy']
const relationships = parseRelationships(source)
const id = input.id ?? randomUUID()
const next: CharacterRelationship = {
id,
targetId: input.targetId,
label: input.label.trim(),
intimacy
}
const idx = relationships.findIndex((r) => r.id === id)
if (idx >= 0) relationships[idx] = next
else relationships.push(next)
repo.update(sourceId, {
properties: { ...source.properties, relationships }
})
return next
}
deleteRelationship(bookId: string, sourceId: string, relationshipId: string): void {
const repo = new SettingRepository(this.registry.getDb(bookId))
const source = repo.get(sourceId)
if (!source) throw new Error('Setting not found')
const relationships = parseRelationships(source).filter((r) => r.id !== relationshipId)
repo.update(sourceId, {
properties: { ...source.properties, relationships }
})
}
}
+3 -1
View File
@@ -52,7 +52,9 @@ export class CockpitService {
}
shouldShowOnOpen(): boolean {
return new BookPrefsRepository(this.db).get('cockpitSeen') !== 'true'
const prefs = new BookPrefsRepository(this.db)
if (prefs.get('alwaysShowCockpit') === 'true') return true
return prefs.get('cockpitSeen') !== 'true'
}
markSeen(): void {
+44
View File
@@ -0,0 +1,44 @@
import type { ComplianceMatch, ComplianceScanResult } from '../../shared/compliance'
import { BUILTIN_COMPLIANCE_WORDS } from '../../shared/compliance'
import type { GlobalSettingsService } from './global-settings'
export class ComplianceService {
constructor(private settings: GlobalSettingsService) {}
getWords(): string[] {
const custom = this.settings.get().complianceWords ?? []
return [...BUILTIN_COMPLIANCE_WORDS, ...custom.filter(Boolean)]
}
setWords(words: string[]): void {
this.settings.update({ complianceWords: words })
}
scanText(text: string): ComplianceScanResult {
const words = this.getWords()
const matches: ComplianceMatch[] = []
const lower = text.toLowerCase()
for (const word of words) {
const w = word.trim()
if (!w) continue
let start = 0
while (start < lower.length) {
const idx = lower.indexOf(w.toLowerCase(), start)
if (idx < 0) break
matches.push({ word: w, index: idx, length: w.length })
start = idx + w.length
}
}
matches.sort((a, b) => a.index - b.index)
return { matches, text }
}
applyReplacements(text: string, replacements: Record<string, string>): string {
let out = text
for (const [from, to] of Object.entries(replacements)) {
if (!from) continue
out = out.split(from).join(to)
}
return out
}
}
+217
View File
@@ -0,0 +1,217 @@
import { BrowserWindow, dialog } from 'electron'
import { writeFileSync } from 'fs'
import type { ExportMatrixParams } from '../../shared/export-matrix'
import type { ExportChapterBlock } from '../lib/export-content'
import {
buildExportBlocks,
buildMdExport,
buildTxtExport,
resolveExportChapters
} from '../lib/export-content'
import type { BookRegistryService } from './book-registry'
import type { GlobalSettingsService } from './global-settings'
const FORMAT_EXT: Record<ExportMatrixParams['format'], string> = {
txt: 'txt',
md: 'md',
docx: 'docx',
pdf: 'pdf'
}
export class ExportMatrixService {
constructor(
private registry: BookRegistryService,
private settings: GlobalSettingsService
) {}
collectBlocks(params: ExportMatrixParams): ExportChapterBlock[] {
const db = this.registry.getDb(params.bookId)
const chapters = resolveExportChapters(db, params.scope, params.scopeId)
if (chapters.length === 0) throw new Error('No chapters to export')
return buildExportBlocks(db, chapters, params.options)
}
async exportToPath(params: ExportMatrixParams, destPath: string): Promise<void> {
const blocks = this.collectBlocks(params)
const book = this.registry.list().find((b) => b.id === params.bookId)
const bookName = book?.name ?? 'export'
const { penName } = this.settings.get()
switch (params.format) {
case 'txt':
writeFileSync(destPath, buildTxtExport(blocks), 'utf8')
break
case 'md':
writeFileSync(destPath, buildMdExport(blocks), 'utf8')
break
case 'docx':
writeFileSync(destPath, await this.buildDocxBuffer(blocks, bookName))
break
case 'pdf':
await this.exportPdf(blocks, bookName, penName, destPath)
break
default:
throw new Error(`Unsupported format: ${params.format}`)
}
}
async pickAndExport(params: ExportMatrixParams): Promise<string | null> {
const ext = FORMAT_EXT[params.format]
let destPath: string | null = null
if (process.env.BILIN_E2E === '1' && process.env.BILIN_E2E_PDF_SAVE) {
destPath = process.env.BILIN_E2E_PDF_SAVE
} else {
const book = this.registry.list().find((b) => b.id === params.bookId)
const defaultPath = `${book?.name ?? 'export'}.${ext}`
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath,
filters: [{ name: ext.toUpperCase(), extensions: [ext] }]
})
if (canceled || !filePath) return null
destPath = filePath
}
await this.exportToPath(params, destPath)
return destPath
}
private async buildDocxBuffer(blocks: ExportChapterBlock[], bookName: string): Promise<Buffer> {
const docx = await import('docx')
const {
Document,
Packer,
Paragraph,
TextRun,
HeadingLevel,
TableOfContents,
AlignmentType,
PageBreak
} = docx
const children: InstanceType<typeof Paragraph>[] = [
new Paragraph({
text: bookName,
heading: HeadingLevel.TITLE,
alignment: AlignmentType.CENTER
}),
new TableOfContents('目录', { hyperlink: true, headingStyleRange: '1-1' }),
new Paragraph({ children: [new PageBreak()] })
]
for (const block of blocks) {
children.push(
new Paragraph({
text: block.title,
heading: HeadingLevel.HEADING_1
})
)
if (block.hasAiContent) {
children.push(
new Paragraph({
children: [new TextRun({ text: '[AI生成内容]', italics: true, color: '888888' })]
})
)
}
for (const p of block.paragraphs) {
children.push(new Paragraph({ children: [new TextRun(p)] }))
}
if (block.annotations.length > 0) {
children.push(
new Paragraph({
children: [new TextRun({ text: '批注', bold: true })]
})
)
for (const note of block.annotations) {
children.push(
new Paragraph({
children: [new TextRun({ text: `${note}`, size: 20 })]
})
)
}
}
}
const doc = new Document({
features: { updateFields: true },
sections: [{ children }]
})
return Packer.toBuffer(doc)
}
private buildPdfHtml(blocks: ExportChapterBlock[], bookName: string, penName: string): string {
const exportedAt = new Date().toLocaleDateString('zh-CN')
const chaptersHtml = blocks
.map((block, idx) => {
const ai = block.hasAiContent ? '<p class="ai-badge">[AI生成内容]</p>' : ''
const body = block.paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`).join('')
const notes =
block.annotations.length > 0
? `<div class="annotations"><strong>批注</strong><ul>${block.annotations.map((a) => `<li>${escapeHtml(a)}</li>`).join('')}</ul></div>`
: ''
return `<section class="chapter"><h2>${idx + 1}. ${escapeHtml(block.title)}</h2>${ai}${body}${notes}</section>`
})
.join('')
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<style>
@page { margin: 2cm 2cm 2.5cm 2cm; }
body { font-family: 'Noto Serif SC', Georgia, serif; font-size: 12pt; line-height: 1.8; color: #222; }
.print-header { font-size: 9pt; color: #666; border-bottom: 1px solid #ccc; padding-bottom: 6px; margin-bottom: 24px; display: flex; justify-content: space-between; }
.chapter { page-break-before: always; }
.chapter:first-of-type { page-break-before: auto; }
h2 { font-size: 16pt; margin: 0 0 12px; }
.ai-badge { font-size: 9pt; color: #888; }
.annotations { font-size: 9pt; color: #555; margin-top: 16px; border-top: 1px dashed #ccc; padding-top: 8px; }
.print-footer { font-size: 9pt; color: #666; margin-top: 32px; border-top: 1px solid #ccc; padding-top: 6px; display: flex; justify-content: space-between; }
</style>
</head>
<body>
<div class="print-header">
<span>${escapeHtml(bookName)}</span>
<span></span>
<span></span>
</div>
${chaptersHtml}
<div class="print-footer">
<span>${escapeHtml(penName)}</span>
<span>${exportedAt}</span>
</div>
</body>
</html>`
}
private async exportPdf(
blocks: ExportChapterBlock[],
bookName: string,
penName: string,
destPath: string
): Promise<void> {
const html = this.buildPdfHtml(blocks, bookName, penName)
const win = new BrowserWindow({
show: false,
webPreferences: { offscreen: true }
})
try {
await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
await new Promise<void>((resolve) => setTimeout(resolve, 300))
const pdf = await win.webContents.printToPDF({
printBackground: true,
pageSize: 'A4',
margins: { top: 0.5, bottom: 0.5, left: 0.5, right: 0.5 }
})
writeFileSync(destPath, pdf)
} finally {
win.destroy()
}
}
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
+55
View File
@@ -0,0 +1,55 @@
import { clipboard, dialog } from 'electron'
import { writeFileSync } from 'fs'
import {
mergeSubmissionPresetsForMain,
MAIN_BUILTIN_SUBMISSION_PRESET
} from '../lib/submission-presets'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { formatChapterForSubmission } from '../lib/submission-formatter'
import type { BookRegistryService } from './book-registry'
import type { GlobalSettingsService } from './global-settings'
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 chapterRepo = new ChapterRepository(db)
const chapter = chapterRepo.get(chapterId)
if (!chapter) throw new Error('Chapter not found')
const presets = mergeSubmissionPresetsForMain(this.settings.get().submissionPresets)
const preset =
presets.find((p) => p.id === presetId) ?? MAIN_BUILTIN_SUBMISSION_PRESET
const volumeChapters = chapterRepo
.list()
.filter((c) => c.volumeId === chapter.volumeId)
.sort((a, b) => a.sortOrder - b.sortOrder)
const chapterNumber = volumeChapters.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
}
}
+40
View File
@@ -0,0 +1,40 @@
import type { BilinExtension, ExtensionManifestEntry } from '../../shared/extension-api'
export class ExtensionRegistry {
private extensions = new Map<string, ExtensionManifestEntry>()
register(manifest: BilinExtension, sourcePath?: string): ExtensionManifestEntry {
const errors: string[] = []
if (!manifest.name?.trim()) errors.push('name required')
if (!manifest.version?.trim()) errors.push('version required')
if (!manifest.main?.trim()) errors.push('main required')
const entry: ExtensionManifestEntry = {
id: manifest.name,
manifest,
sourcePath,
valid: errors.length === 0,
errors
}
this.extensions.set(entry.id, entry)
return entry
}
list(): ExtensionManifestEntry[] {
return [...this.extensions.values()]
}
validateManifest(raw: unknown): ExtensionManifestEntry {
const manifest = raw as BilinExtension
return this.register(manifest)
}
registerImporter(_id: string, _extensions: string[]): void {
/* stub */
}
registerExporter(_id: string, _extensions: string[]): void {
/* stub */
}
}
export const extensionRegistry = new ExtensionRegistry()
+20 -1
View File
@@ -35,7 +35,16 @@ function defaults(): GlobalSettings {
knowledgeExtractConfidenceThreshold: 0.8,
pomodoroDurationMinutes: 25,
enableSystemNotifications: false,
enableGoalNotifications: true
enableGoalNotifications: true,
chapterTemplates: [],
submissionPresets: [],
defaultSubmissionPresetId: 'builtin-webnovel',
enableComplianceCheck: false,
complianceWords: [],
customSlashCommands: [],
syncConfig: null,
extensionDevMode: false,
mcpConnectorUrl: ''
}
}
@@ -64,6 +73,16 @@ export class GlobalSettingsService {
enableSystemNotifications: raw.enableSystemNotifications ?? false,
enableGoalNotifications: raw.enableGoalNotifications ?? true,
dailyGoalNotifiedDate: raw.dailyGoalNotifiedDate,
chapterTemplates: raw.chapterTemplates ?? [],
submissionPresets: raw.submissionPresets ?? [],
defaultSubmissionPresetId: raw.defaultSubmissionPresetId ?? 'builtin-webnovel',
enableComplianceCheck: raw.enableComplianceCheck ?? false,
complianceWords: raw.complianceWords ?? [],
customSlashCommands: raw.customSlashCommands ?? [],
syncConfig: raw.syncConfig ?? null,
syncPasswordVerifier: raw.syncPasswordVerifier,
extensionDevMode: raw.extensionDevMode ?? false,
mcpConnectorUrl: raw.mcpConnectorUrl ?? '',
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig }
}
+119
View File
@@ -0,0 +1,119 @@
import { basename } from 'node:path'
import { readFileSync, statSync } from 'node:fs'
import { dialog } from 'electron'
import type {
ImportExecuteParams,
ImportPreviewResult,
ImportSplitMode
} from '../../shared/types'
import { splitPlainText } from '../lib/chapter-splitter'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { VolumeRepository } from '../db/repositories/volume.repo'
import { countWords, stripHtml } from './word-count'
import type { BookRegistryService } from './book-registry'
function plainTextToEditorHtml(text: string): string {
const trimmed = text.trim()
if (!trimmed) return '<p></p>'
return trimmed
.split(/\n{2,}/)
.map((p) => {
const safe = p
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
return '<p>' + safe + '</p>'
})
.join('')
}
async function readHtmlFromFile(filePath: string): Promise<string> {
const lower = filePath.toLowerCase()
if (lower.endsWith('.docx')) {
const mod = await import('mammoth')
return (await mod.default.convertToHtml({ path: filePath })).value
}
const raw = readFileSync(filePath, 'utf8')
if (lower.endsWith('.md')) {
const mod = await import('marked')
return mod.marked.parse(raw) as string
}
return plainTextToEditorHtml(raw)
}
export class ImportService {
constructor(private registry: BookRegistryService) {}
async pickFile(): Promise<{ canceled: boolean; filePath?: string }> {
if (process.env.BILIN_E2E === '1' && process.env.BILIN_E2E_IMPORT_FILE) {
return { canceled: false, filePath: process.env.BILIN_E2E_IMPORT_FILE }
}
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Import', extensions: ['txt', 'md', 'docx'] }]
})
if (canceled || filePaths.length === 0) return { canceled: true }
return { canceled: false, filePath: filePaths[0] }
}
async preview(
filePath: string,
splitMode: ImportSplitMode,
customRegex?: string
): Promise<ImportPreviewResult> {
const stat = statSync(filePath)
const html = await readHtmlFromFile(filePath)
const plain = stripHtml(html)
const parts = splitPlainText(plain, splitMode, customRegex)
const warnings: string[] = []
if (parts.length === 0) warnings.push('No chapters detected')
const chapters = parts.map((p) => {
const contentHtml = plainTextToEditorHtml(p.body)
return {
title: p.title || 'Untitled',
contentHtml,
wordCount: countWords(contentHtml)
}
})
return {
fileName: basename(filePath),
fileSizeBytes: stat.size,
chapters,
warnings
}
}
async execute(
params: ImportExecuteParams,
onProgress?: (current: number, total: number) => void
): Promise<string> {
const preview = await this.preview(params.filePath, params.splitMode, params.customRegex)
if (preview.chapters.length === 0) throw new Error('No chapters to import')
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
const chapterRepo = new ChapterRepository(db)
for (let i = 0; i < preview.chapters.length; i++) {
const ch = preview.chapters[i]!
chapterRepo.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
}
}
}
+80
View File
@@ -0,0 +1,80 @@
import { app } from 'electron'
import log from 'electron-log'
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'fs'
import { join } from 'path'
const CRASH_FLAG = 'crash.pending'
const LOG_RETENTION_DAYS = 7
export class LogService {
private logDir: string
private logFile: string
private crashFlagPath: string
constructor(userDataDir: string) {
this.logDir = join(userDataDir, 'logs')
this.logFile = join(this.logDir, 'bilin.log')
this.crashFlagPath = join(userDataDir, CRASH_FLAG)
if (!existsSync(this.logDir)) mkdirSync(this.logDir, { recursive: true })
log.transports.file.resolvePathFn = () => this.logFile
log.transports.file.maxSize = 5 * 1024 * 1024
log.transports.console.level = process.env.BILIN_E2E === '1' ? 'error' : 'info'
this.pruneOldLogs()
this.installGlobalHandlers()
}
getLogger(): typeof log {
return log
}
hadCrashOnLastRun(): boolean {
return existsSync(this.crashFlagPath)
}
clearCrashFlag(): void {
if (existsSync(this.crashFlagPath)) rmSync(this.crashFlagPath)
}
markUncleanExit(): void {
writeFileSync(this.crashFlagPath, new Date().toISOString(), 'utf-8')
}
sendReportStub(note?: string): { ok: true; loggedAt: string } {
const msg = note ? `User report: ${note}` : 'User report submitted (stub)'
log.error(msg)
this.clearCrashFlag()
return { ok: true, loggedAt: new Date().toISOString() }
}
logManualError(message: string, detail?: unknown): void {
log.error(message, detail)
}
private installGlobalHandlers(): void {
process.on('uncaughtException', (err) => {
log.error('uncaughtException', err)
this.markUncleanExit()
})
process.on('unhandledRejection', (reason) => {
log.error('unhandledRejection', reason)
})
app?.on?.('before-quit', () => {
this.clearCrashFlag()
})
}
private pruneOldLogs(): void {
if (!existsSync(this.logDir)) return
const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000
for (const name of readdirSync(this.logDir)) {
const full = join(this.logDir, name)
try {
if (statSync(full).mtimeMs < cutoff) rmSync(full, { force: true })
} catch {
/* ignore */
}
}
}
}
+291
View File
@@ -0,0 +1,291 @@
import { app, BrowserWindow } from 'electron'
import {
copyFileSync,
createWriteStream,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync
} from 'fs'
import { IPC } from '../../shared/ipc-channels'
import { basename, join } from 'path'
import { tmpdir } from 'os'
import { randomUUID } from 'crypto'
import extract from 'extract-zip'
import { ZipArchive } from 'archiver'
import type { BookMeta } from '../../shared/types'
import {
NOVEL_ALL_FORMAT_VERSION,
NOVEL_FORMAT_VERSION,
type NovelAllManifest,
type NovelManifest,
type PackImportReport,
type PackImportStrategy,
type PackProgressEvent
} from '../../shared/novel-pack'
import type { BookRegistryService } from './book-registry'
import type { GlobalSettingsService } from './global-settings'
import { closeAllBookDbs } from '../db/connection'
const SIZE_CONFIRM_BYTES = 500 * 1024 * 1024
type ProgressFn = (event: PackProgressEvent) => void
function appVersion(): string {
try {
return app.getVersion()
} catch {
return '1.3.0'
}
}
function broadcastProgress(event: PackProgressEvent): void {
try {
const windows = BrowserWindow?.getAllWindows?.()
if (!windows) return
for (const win of windows) {
win.webContents.send(IPC.PACK_PROGRESS, event)
}
} catch {
// non-Electron test environment
}
}
export class PackService {
private abort = false
constructor(
private registry: BookRegistryService,
private settings: GlobalSettingsService,
private userDataDir: string
) {}
cancel(): void {
this.abort = true
}
resetAbort(): void {
this.abort = false
}
estimateExportAllBytes(): number {
let total = 0
for (const book of this.registry.list()) {
const dbPath = join(this.userDataDir, book.dbPath)
if (existsSync(dbPath)) total += statSync(dbPath).size
if (book.coverPath && existsSync(book.coverPath)) {
total += statSync(book.coverPath).size
}
}
const settingsPath = join(this.userDataDir, 'global_settings.json')
if (existsSync(settingsPath)) total += statSync(settingsPath).size
return total
}
needsExportAllConfirm(): boolean {
return this.estimateExportAllBytes() > SIZE_CONFIRM_BYTES
}
private resolveCoverFromAttachments(tmpDir: string): string | null {
const attachmentsDir = join(tmpDir, 'attachments')
if (!existsSync(attachmentsDir)) return null
const coverFile = readdirSync(attachmentsDir)[0]
if (!coverFile) return null
const coversDir = join(this.userDataDir, 'covers')
mkdirSync(coversDir, { recursive: true })
const ext = basename(coverFile).includes('.') ? basename(coverFile).slice(basename(coverFile).lastIndexOf('.')) : ''
const newCover = join(coversDir, `${randomUUID()}${ext}`)
copyFileSync(join(attachmentsDir, coverFile), newCover)
return newCover
}
async exportBook(bookId: string, destPath: string): Promise<void> {
const meta = this.registry.getMeta(bookId)
if (!meta) throw new Error('Book not found')
const output = createWriteStream(destPath)
const archive = new ZipArchive({ zlib: { level: 9 } })
archive.pipe(output)
const manifest: NovelManifest = {
formatVersion: NOVEL_FORMAT_VERSION,
appVersion: appVersion(),
exportedAt: new Date().toISOString(),
bookId: meta.id,
bookName: meta.name
}
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' })
archive.append(JSON.stringify(meta, null, 2), { name: 'meta.json' })
const dbPath = join(this.userDataDir, meta.dbPath)
if (!existsSync(dbPath)) throw new Error('Book database missing')
archive.file(dbPath, { name: 'book.sqlite' })
if (meta.coverPath && existsSync(meta.coverPath)) {
archive.file(meta.coverPath, { name: `attachments/${basename(meta.coverPath)}` })
}
const done = new Promise<void>((resolve, reject) => {
output.on('close', () => resolve())
output.on('error', reject)
archive.on('error', reject)
})
void archive.finalize()
await done
}
async importBook(filePath: string): Promise<string> {
const tmpDir = mkdtempSync(join(tmpdir(), 'bilin-novel-'))
try {
await extract(filePath, { dir: tmpDir })
const manifestPath = join(tmpDir, 'manifest.json')
const metaPath = join(tmpDir, 'meta.json')
const sqlitePath = join(tmpDir, 'book.sqlite')
if (!existsSync(manifestPath) || !existsSync(metaPath) || !existsSync(sqlitePath)) {
throw new Error('Invalid .novel package')
}
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as NovelManifest
if (manifest.formatVersion !== NOVEL_FORMAT_VERSION) {
throw new Error('Unsupported .novel format version')
}
const meta = JSON.parse(readFileSync(metaPath, 'utf8')) as BookMeta
closeAllBookDbs()
const imported = this.registry.importFromPack(meta, sqlitePath, this.resolveCoverFromAttachments(tmpDir))
return imported.id
} finally {
rmSync(tmpDir, { recursive: true, force: true })
}
}
async exportAll(destPath: string, onProgress?: ProgressFn): Promise<void> {
this.resetAbort()
const books = this.registry.list()
const total = books.length + 1
let current = 0
const report = (phase: PackProgressEvent['phase'], message?: string): void => {
const event = { current, total, phase, message }
onProgress?.(event)
broadcastProgress(event)
}
const output = createWriteStream(destPath)
const archive = new ZipArchive({ zlib: { level: 9 } })
archive.pipe(output)
const manifest: NovelAllManifest = {
formatVersion: NOVEL_ALL_FORMAT_VERSION,
appVersion: appVersion(),
exportedAt: new Date().toISOString(),
bookCount: books.length
}
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' })
const settingsPath = join(this.userDataDir, 'global_settings.json')
if (existsSync(settingsPath)) {
archive.file(settingsPath, { name: 'global_settings.json' })
} else {
archive.append(JSON.stringify(this.settings.get(), null, 2), { name: 'global_settings.json' })
}
for (const book of books) {
if (this.abort) throw new Error('Export cancelled')
current += 1
report('export', book.name)
archive.append(JSON.stringify(book, null, 2), { name: `books/${book.id}/meta.json` })
const dbPath = join(this.userDataDir, book.dbPath)
if (existsSync(dbPath)) {
archive.file(dbPath, { name: `books/${book.id}/book.sqlite` })
}
}
current = total
report('export', 'finalize')
const done = new Promise<void>((resolve, reject) => {
output.on('close', () => resolve())
output.on('error', reject)
archive.on('error', reject)
})
void archive.finalize()
await done
}
async importAll(
filePath: string,
strategy: PackImportStrategy,
onProgress?: ProgressFn
): Promise<PackImportReport> {
this.resetAbort()
const tmpDir = mkdtempSync(join(tmpdir(), 'bilin-novel-all-'))
const report: PackImportReport = { imported: 0, skipped: 0, overwritten: 0, books: [] }
try {
await extract(filePath, { dir: tmpDir })
const manifest = JSON.parse(
readFileSync(join(tmpDir, 'manifest.json'), 'utf8')
) as NovelAllManifest
if (manifest.formatVersion !== NOVEL_ALL_FORMAT_VERSION) {
throw new Error('Unsupported .novel-all format version')
}
const settingsFile = join(tmpDir, 'global_settings.json')
if (existsSync(settingsFile)) {
this.settings.update(JSON.parse(readFileSync(settingsFile, 'utf8')))
}
const booksDir = join(tmpDir, 'books')
if (!existsSync(booksDir)) return report
const bookIds = readdirSync(booksDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
const total = bookIds.length
closeAllBookDbs()
for (let i = 0; i < bookIds.length; i++) {
if (this.abort) throw new Error('Import cancelled')
const sourceId = bookIds[i]
const bookDir = join(booksDir, sourceId)
const meta = JSON.parse(readFileSync(join(bookDir, 'meta.json'), 'utf8')) as BookMeta
const sqlitePath = join(bookDir, 'book.sqlite')
onProgress?.({ current: i + 1, total, phase: 'import', message: meta.name })
broadcastProgress({ current: i + 1, total, phase: 'import', message: meta.name })
const existing = this.registry.getMeta(sourceId)
if (existing && strategy === 'skip') {
report.skipped += 1
report.books.push({
sourceBookId: sourceId,
targetBookId: null,
bookName: meta.name,
action: 'skipped'
})
continue
}
if (existing && strategy === 'overwrite') {
this.registry.replaceFromPack(existing.id, meta, sqlitePath)
report.overwritten += 1
report.books.push({
sourceBookId: sourceId,
targetBookId: existing.id,
bookName: meta.name,
action: 'overwritten'
})
continue
}
const imported = this.registry.importFromPack(meta, sqlitePath, null)
report.imported += 1
report.books.push({
sourceBookId: sourceId,
targetBookId: imported.id,
bookName: imported.name,
action: 'imported'
})
}
return report
} finally {
rmSync(tmpDir, { recursive: true, force: true })
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import { createCipheriv, createDecipheriv, createHash, pbkdf2Sync, randomBytes } from 'crypto'
import { readFileSync, writeFileSync } from 'fs'
const ALGO = 'aes-256-gcm'
const KEY_LEN = 32
const IV_LEN = 12
const TAG_LEN = 16
const PBKDF2_ITERATIONS = 120_000
export function hashSyncPassword(password: string, salt: string): string {
return createHash('sha256').update(`${salt}:${password}`).digest('hex')
}
export function deriveKey(password: string, salt: Buffer): Buffer {
return pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LEN, 'sha256')
}
export function generateSalt(): string {
return randomBytes(16).toString('hex')
}
export function encryptFile(inputPath: string, outputPath: string, password: string, saltHex: string): void {
const salt = Buffer.from(saltHex, 'hex')
const key = deriveKey(password, salt)
const iv = randomBytes(IV_LEN)
const plain = readFileSync(inputPath)
const cipher = createCipheriv(ALGO, key, iv)
const encrypted = Buffer.concat([cipher.update(plain), cipher.final()])
const tag = cipher.getAuthTag()
const out = Buffer.concat([salt, iv, tag, encrypted])
writeFileSync(outputPath, out)
}
export function decryptFile(inputPath: string, outputPath: string, password: string): void {
const buf = readFileSync(inputPath)
const salt = buf.subarray(0, 16)
const iv = buf.subarray(16, 16 + IV_LEN)
const tag = buf.subarray(16 + IV_LEN, 16 + IV_LEN + TAG_LEN)
const data = buf.subarray(16 + IV_LEN + TAG_LEN)
const key = deriveKey(password, salt)
const decipher = createDecipheriv(ALGO, key, iv)
decipher.setAuthTag(tag)
const plain = Buffer.concat([decipher.update(data), decipher.final()])
writeFileSync(outputPath, plain)
}
export const SYNC_BUNDLE_NAME = 'bilin-sync.novel-all.enc'
+243
View File
@@ -0,0 +1,243 @@
import { app, BrowserWindow } from 'electron'
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync
} from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { IPC } from '../../shared/ipc-channels'
import type { SyncConfig, SyncProgressEvent, SyncRunResult } from '../../shared/sync'
import type { BookRegistryService } from './book-registry'
import type { GlobalSettingsService } from './global-settings'
import { PackService } from './pack.service'
import { decryptFile, encryptFile, generateSalt, hashSyncPassword, SYNC_BUNDLE_NAME } from './sync-crypto'
function broadcastProgress(event: SyncProgressEvent): void {
try {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(IPC.SYNC_PROGRESS, event)
}
} catch {
/* test env */
}
}
export class SyncService {
private scheduleTimer: ReturnType<typeof setInterval> | null = null
private sessionPassword: string | null = null
constructor(
private registry: BookRegistryService,
private settings: GlobalSettingsService,
private userDataDir: string
) {}
getStatus(): SyncConfig | null {
return this.settings.get().syncConfig ?? null
}
configure(input: { config: SyncConfig; password: string }): SyncConfig {
if (input.password.length < 8) {
throw new Error('同步密码至少 8 位')
}
const salt = input.config.syncSalt ?? generateSalt()
const next: SyncConfig = {
...input.config,
syncSalt: salt,
passwordConfigured: true,
lastSyncStatus: 'idle'
}
this.sessionPassword = input.password
this.settings.update({
syncConfig: next,
syncPasswordVerifier: hashSyncPassword(input.password, salt)
})
this.applySchedule()
return next
}
setSessionPassword(password: string): void {
const cfg = this.settings.get().syncConfig
const salt = cfg?.syncSalt
const verifier = this.settings.get().syncPasswordVerifier
if (!salt || !verifier) throw new Error('尚未配置同步')
if (hashSyncPassword(password, salt) !== verifier) {
throw new Error('同步密码错误')
}
this.sessionPassword = password
}
async run(
password: string,
conflictResolution?: 'local' | 'remote' | 'cancel'
): Promise<SyncRunResult> {
this.setSessionPassword(password)
const cfg = this.settings.get().syncConfig
if (!cfg?.passwordConfigured || !cfg.syncSalt) {
throw new Error('请先完成同步配置')
}
const tempDir = mkdtempSync(join(tmpdir(), 'bilin-sync-'))
const plainPath = join(tempDir, 'bundle.novel-all')
const encPath = join(tempDir, SYNC_BUNDLE_NAME)
try {
broadcastProgress({ phase: 'export', message: '正在打包书籍…', percent: 10 })
const pack = new PackService(this.registry, this.settings, this.userDataDir)
await pack.exportAll(plainPath, (p) =>
broadcastProgress({
phase: 'export',
message: p.message,
percent: 10 + Math.round((p.current / Math.max(p.total, 1)) * 30)
})
)
const remotePath = this.remoteBundlePath(cfg)
const remoteExists = await this.remoteExists(cfg, remotePath)
const remoteMtime = await this.remoteMtime(cfg, remotePath)
const localEditedAt = Date.now()
const lastSyncAt = cfg.lastSyncAt ? Date.parse(cfg.lastSyncAt) : 0
const remoteNewer = remoteExists && remoteMtime > lastSyncAt
const localNewer = localEditedAt > lastSyncAt
if (remoteExists && remoteNewer && localNewer && !conflictResolution) {
this.patchStatus('conflict')
return {
status: 'conflict',
syncedAt: new Date().toISOString(),
remoteNewer: true,
localNewer: true
}
}
if (conflictResolution === 'cancel') {
return { status: 'idle', syncedAt: new Date().toISOString() }
}
if (conflictResolution === 'remote' && remoteExists) {
broadcastProgress({ phase: 'download', message: '正在下载远程备份…', percent: 60 })
const downloaded = join(tempDir, 'remote.enc')
await this.fetchRemote(cfg, remotePath, downloaded)
broadcastProgress({ phase: 'decrypt', message: '正在解密…', percent: 75 })
const decrypted = join(tempDir, 'restored.novel-all')
decryptFile(downloaded, decrypted, password)
broadcastProgress({ phase: 'import', message: '正在导入…', percent: 90 })
await pack.importAll(decrypted, 'merge')
} else {
broadcastProgress({ phase: 'encrypt', message: '正在加密…', percent: 50 })
encryptFile(plainPath, encPath, password, cfg.syncSalt)
broadcastProgress({ phase: 'upload', message: '正在同步…', percent: 70 })
await this.pushRemote(cfg, remotePath, encPath)
}
const syncedAt = new Date().toISOString()
this.patchStatus('ok', syncedAt)
broadcastProgress({ phase: 'done', message: '同步完成', percent: 100 })
return { status: 'ok', syncedAt }
} catch (err) {
this.patchStatus('error')
throw err
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
}
applySchedule(): void {
if (this.scheduleTimer) clearInterval(this.scheduleTimer)
const cfg = this.settings.get().syncConfig
if (!cfg || cfg.schedule === 'manual' || !this.sessionPassword) return
const ms =
cfg.schedule === 'hourly'
? 60 * 60 * 1000
: cfg.schedule === 'daily'
? 24 * 60 * 60 * 1000
: 7 * 24 * 60 * 60 * 1000
this.scheduleTimer = setInterval(() => {
if (!this.sessionPassword) return
void this.run(this.sessionPassword).catch(() => this.patchStatus('error'))
}, ms)
}
private patchStatus(status: SyncConfig['lastSyncStatus'], lastSyncAt?: string): void {
const current = this.settings.get().syncConfig
if (!current) return
this.settings.update({
syncConfig: {
...current,
lastSyncStatus: status,
lastSyncAt: lastSyncAt ?? current.lastSyncAt ?? null
}
})
}
private remoteBundlePath(cfg: SyncConfig): string {
if (cfg.mode === 'local-folder') {
if (!cfg.localFolderPath) throw new Error('未配置本地同步目录')
return join(cfg.localFolderPath, SYNC_BUNDLE_NAME)
}
if (!cfg.webdavUrl) throw new Error('未配置 WebDAV 地址')
const base = cfg.webdavUrl.replace(/\/$/, '')
return `${base}/${SYNC_BUNDLE_NAME}`
}
private async remoteExists(cfg: SyncConfig, remotePath: string): Promise<boolean> {
if (cfg.mode === 'local-folder') return existsSync(remotePath)
try {
const res = await fetch(remotePath, { method: 'HEAD' })
return res.ok
} catch {
return false
}
}
private async remoteMtime(cfg: SyncConfig, remotePath: string): Promise<number> {
if (cfg.mode === 'local-folder') {
return existsSync(remotePath) ? statSync(remotePath).mtimeMs : 0
}
try {
const res = await fetch(remotePath, { method: 'HEAD' })
const lm = res.headers.get('last-modified')
return lm ? Date.parse(lm) : 0
} catch {
return 0
}
}
private async pushRemote(cfg: SyncConfig, remotePath: string, localEncPath: string): Promise<void> {
if (cfg.mode === 'local-folder') {
mkdirSync(cfg.localFolderPath!, { recursive: true })
copyFileSync(localEncPath, remotePath)
return
}
const body = readFileSync(localEncPath)
const headers: Record<string, string> = { 'Content-Type': 'application/octet-stream' }
if (cfg.webdavUser && this.sessionPassword) {
const token = Buffer.from(`${cfg.webdavUser}:${this.sessionPassword}`).toString('base64')
headers.Authorization = `Basic ${token}`
}
const res = await fetch(remotePath, { method: 'PUT', headers, body })
if (!res.ok) throw new Error(`WebDAV 上传失败: ${res.status}`)
}
private async fetchRemote(cfg: SyncConfig, remotePath: string, destPath: string): Promise<void> {
if (cfg.mode === 'local-folder') {
copyFileSync(remotePath, destPath)
return
}
const headers: Record<string, string> = {}
if (cfg.webdavUser && this.sessionPassword) {
const token = Buffer.from(`${cfg.webdavUser}:${this.sessionPassword}`).toString('base64')
headers.Authorization = `Basic ${token}`
}
const res = await fetch(remotePath, { headers })
if (!res.ok) throw new Error(`WebDAV 下载失败: ${res.status}`)
const buf = Buffer.from(await res.arrayBuffer())
writeFileSync(destPath, buf)
}
}
+133
View File
@@ -0,0 +1,133 @@
import type { Chapter, SettingEntry } from '../../shared/types'
import type { TimelineConflict, TimelineEntry } from '../../shared/timeline'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { SettingRepository } from '../db/repositories/setting.repo'
import { TimelineRepository } from '../db/repositories/timeline.repo'
import type { SqliteDb } from '../db/types'
function parseRelativeDays(storyTime: string): number | null {
const m = storyTime.trim().match(/^\+(\d+)\s*([dDmMyY])$/)
if (!m) return null
const n = Number(m[1])
const unit = m[2].toLowerCase()
if (unit === 'y') return n * 365
if (unit === 'm') return n * 30
return n
}
function parseAbsoluteYear(storyTime: string): number | null {
const digits = storyTime.replace(/\D/g, '')
if (!digits) return null
const year = Number(digits)
return Number.isFinite(year) ? year : null
}
function resolveTimelinePosition(chapter: Chapter, prev?: Chapter): number {
if (!chapter.storyTime) return chapter.sortOrder
const rel = parseRelativeDays(chapter.storyTime)
if (rel != null && prev) {
const prevPos = resolveTimelinePosition(prev)
return prevPos + rel
}
const abs = parseAbsoluteYear(chapter.storyTime)
if (abs != null) return abs
return chapter.sortOrder
}
function birthYearFromSetting(setting: SettingEntry): number | null {
const props = setting.properties ?? {}
const birthDate = props.birthDate
if (typeof birthDate === 'string' || typeof birthDate === 'number') {
const year = parseAbsoluteYear(String(birthDate))
if (year != null) return year
}
const age = props.age
if (typeof age === 'number') return null
if (typeof age === 'string' && age.trim()) {
const n = Number(age.replace(/\D/g, ''))
return Number.isFinite(n) ? null : null
}
return null
}
function ageAtPosition(birthYear: number, position: number): number {
return Math.max(0, Math.floor(position - birthYear))
}
export class TimelineService {
constructor(private db: SqliteDb) {}
list(): TimelineEntry[] {
const chapters = new ChapterRepository(this.db).list().sort((a, b) => a.sortOrder - b.sortOrder)
const chapterEntries: TimelineEntry[] = chapters.map((ch) => ({
id: `chapter:${ch.id}`,
kind: 'chapter',
chapterId: ch.id,
title: ch.title,
storyTime: ch.storyTime ?? null,
sortOrder: ch.sortOrder
}))
const manual = new TimelineRepository(this.db).listManual()
const merged = [...chapterEntries, ...manual]
merged.sort((a, b) => {
const chA = chapters.find((c) => c.id === a.chapterId)
const chB = chapters.find((c) => c.id === b.chapterId)
const idxA = chA ? chapters.indexOf(chA) : -1
const idxB = chB ? chapters.indexOf(chB) : -1
const prevA = idxA > 0 ? chapters[idxA - 1] : undefined
const prevB = idxB > 0 ? chapters[idxB - 1] : undefined
const posA = chA ? resolveTimelinePosition(chA, prevA) : a.sortOrder + 10_000
const posB = chB ? resolveTimelinePosition(chB, prevB) : b.sortOrder + 10_000
return posA - posB || a.sortOrder - b.sortOrder
})
return merged
}
upsertManual(input: {
id?: string
title: string
storyTime?: string | null
sortOrder?: number
notes?: string
}): TimelineEntry {
return new TimelineRepository(this.db).upsert(input)
}
deleteManual(id: string): void {
new TimelineRepository(this.db).delete(id)
}
detectConflicts(): TimelineConflict[] {
const chapters = new ChapterRepository(this.db).list().sort((a, b) => a.sortOrder - b.sortOrder)
const characters = new SettingRepository(this.db)
.list()
.filter((s) => s.type === 'character')
const conflicts: TimelineConflict[] = []
for (const character of characters) {
const birthYear = birthYearFromSetting(character)
if (birthYear == null) continue
let lastAge: number | null = null
let lastChapter: Chapter | null = null
for (let i = 0; i < chapters.length; i++) {
const ch = chapters[i]!
const prev = i > 0 ? chapters[i - 1] : undefined
const position = resolveTimelinePosition(ch, prev)
const age = ageAtPosition(birthYear, position)
if (lastAge != null && age < lastAge && ch.storyTime) {
conflicts.push({
characterId: character.id,
characterName: character.name,
chapterId: ch.id,
chapterTitle: ch.title,
message: `${character.name} 年龄从 ${lastAge} 变为 ${age}(时间倒退)`
})
}
lastAge = age
lastChapter = ch
}
if (!lastChapter) continue
}
return conflicts
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { UndoOperation, UndoStackEntry } from '../../shared/undo'
import { UndoStackRepository } from '../db/repositories/undo.repo'
import type { SqliteDb } from '../db/types'
export class UndoStackService {
constructor(private db: SqliteDb) {}
push(chapterId: string, operation: UndoOperation): UndoStackEntry {
return new UndoStackRepository(this.db).push(chapterId, operation)
}
list(chapterId: string): UndoStackEntry[] {
return new UndoStackRepository(this.db).list(chapterId)
}
apply(entryId: number): UndoOperation | null {
const entry = new UndoStackRepository(this.db).get(entryId)
return entry?.operation ?? null
}
}
+98
View File
@@ -0,0 +1,98 @@
import { app, BrowserWindow } from 'electron'
import electronUpdater from 'electron-updater'
import { IPC } from '../../shared/ipc-channels'
const { autoUpdater } = electronUpdater
export interface UpdateInfoPayload {
version: string
releaseNotes?: string
}
export interface UpdateProgressPayload {
percent: number
transferred: number
total: number
}
export class UpdateService {
private disabled: boolean
private mockVersion: string | null = null
constructor() {
if (process.env.BILIN_MOCK_UPDATE === '1') {
this.disabled = false
this.mockVersion = process.env.BILIN_MOCK_UPDATE_VERSION ?? '9.9.9'
return
}
this.disabled =
process.env.BILIN_E2E === '1' ||
!app.isPackaged ||
process.env.NODE_ENV === 'development'
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.on('update-available', (info) => {
this.broadcast(IPC.UPDATE_AVAILABLE, {
version: info.version,
releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined
})
})
autoUpdater.on('download-progress', (p) => {
this.broadcast(IPC.UPDATE_PROGRESS, {
percent: p.percent,
transferred: p.transferred,
total: p.total
})
})
autoUpdater.on('update-downloaded', (info) => {
this.broadcast(IPC.UPDATE_DOWNLOADED, { version: info.version })
})
autoUpdater.on('error', (err) => {
this.broadcast(IPC.UPDATE_ERROR, { message: err.message })
})
}
async check(): Promise<UpdateInfoPayload | null> {
if (this.disabled) return null
if (this.mockVersion) {
const payload = { version: this.mockVersion, releaseNotes: 'Mock update' }
this.broadcast(IPC.UPDATE_AVAILABLE, payload)
return payload
}
const result = await autoUpdater.checkForUpdates()
const info = result?.updateInfo
if (!info) return null
return {
version: info.version,
releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined
}
}
async download(): Promise<void> {
if (this.disabled) return
if (this.mockVersion) {
this.broadcast(IPC.UPDATE_PROGRESS, { percent: 50, transferred: 50, total: 100 })
this.broadcast(IPC.UPDATE_DOWNLOADED, { version: this.mockVersion })
return
}
await autoUpdater.downloadUpdate()
}
install(): void {
if (this.disabled || this.mockVersion) return
autoUpdater.quitAndInstall()
}
private broadcast(channel: string, payload: unknown): void {
try {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(channel, payload)
}
} catch {
/* test env */
}
}
}
@@ -0,0 +1,106 @@
import type { WritingAnalyticsSummary } from '../../shared/types'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { PomodoroDailyRepository } from '../db/repositories/pomodoro-daily.repo'
import { SettingRepository } from '../db/repositories/setting.repo'
import { VolumeRepository } from '../db/repositories/volume.repo'
import { WritingSessionRepository } from '../db/repositories/writing-session.repo'
import type { BookRegistryService } from './book-registry'
import { addDays, localDateString } from './writing-date.util'
import type { WritingLogService } from './writing-log.service'
export const POV_NONE = '__none__'
function minutesBetween(startIso: string, endIso: string): number {
const ms = new Date(endIso).getTime() - new Date(startIso).getTime()
return Math.max(0, ms / 60000)
}
function computeWpm(
sessions: Array<{ wordDelta: number; startedAt: string; endedAt: string }>
): number | null {
const totalWords = sessions.reduce((s, x) => s + x.wordDelta, 0)
const totalMin = sessions.reduce((s, x) => s + minutesBetween(x.startedAt, x.endedAt), 0)
if (totalMin <= 0 || totalWords <= 0) return null
return Math.round(totalWords / totalMin)
}
export class WritingAnalyticsService {
constructor(
private writingLogs: WritingLogService,
private sessionRepo: WritingSessionRepository,
private pomodoroDaily: PomodoroDailyRepository,
private registry: BookRegistryService
) {}
getSummary(bookId: string): WritingAnalyticsSummary {
const today = localDateString()
const todaySessions = this.sessionRepo.listForBookDate(bookId, today)
const todayWords = this.writingLogs.getStats(bookId).todayWords
const todaySpeedWpm = computeWpm(todaySessions)
const weekTrend: { date: string; words: number }[] = []
for (let i = 6; i >= 0; i--) {
const date = addDays(today, -i)
weekTrend.push({ date, words: this.writingLogs.getDayWords(bookId, date) })
}
const sinceIso = addDays(today, -30) + 'T00:00:00.000Z'
const sessions30 = this.sessionRepo.listSince(bookId, sinceIso)
const hourlyHeatmap = Array.from({ length: 24 }, () => 0)
for (const s of sessions30) {
const hour = new Date(s.startedAt).getHours()
hourlyHeatmap[hour] += s.wordDelta
}
const pomodoroToday = this.pomodoroDaily.getTodayCount(bookId)
const pomodoroStats = this.pomodoroDaily.getTodayStats(bookId)
const pomodoroAvgWords =
pomodoroStats.completedCount > 0
? Math.round(pomodoroStats.totalWords / pomodoroStats.completedCount)
: null
const db = this.registry.getDb(bookId)
const chapters = new ChapterRepository(db).list()
const settings = new SettingRepository(db).list('character')
const nameById = new Map(settings.map((s) => [s.id, s.name]))
const povMap = new Map<string, number>()
for (const ch of chapters) {
const key = ch.povCharacterId ?? POV_NONE
povMap.set(key, (povMap.get(key) ?? 0) + ch.wordCount)
}
const povDistribution = [...povMap.entries()]
.map(([characterId, words]) => ({
characterId,
name: characterId === POV_NONE ? '' : (nameById.get(characterId) ?? characterId),
words
}))
.sort((a, b) => b.words - a.words)
const volumes = new VolumeRepository(db).list()
const volTitle = new Map(volumes.map((v) => [v.id, v.name]))
const volMap = new Map<string, number>()
for (const ch of chapters) {
if (!ch.volumeId) continue
volMap.set(ch.volumeId, (volMap.get(ch.volumeId) ?? 0) + ch.wordCount)
}
const volumeDistribution = [...volMap.entries()]
.map(([volumeId, words]) => ({
volumeId,
title: volTitle.get(volumeId) ?? volumeId,
words
}))
.sort((a, b) => b.words - a.words)
return {
todayWords,
todaySpeedWpm,
weekTrend,
hourlyHeatmap,
pomodoroToday,
pomodoroAvgWords,
povDistribution,
volumeDistribution
}
}
}
+4
View File
@@ -43,6 +43,10 @@ export class WritingLogService {
return { todayWords, dailyGoal, goalProgress, streakDays, heatmap }
}
getDayWords(bookId: string, date: string): number {
return this.repo.getDay(bookId, date)
}
private calcStreak(bookId: string, goal: number): number {
let streak = 0
let date = addDays(localDateString(), -1)
@@ -0,0 +1,40 @@
import { randomUUID } from 'crypto'
import { WritingSessionRepository } from '../db/repositories/writing-session.repo'
import { localDateString } from './writing-date.util'
const IDLE_MS = 5 * 60 * 1000
export class WritingSessionService {
private active = new Map<string, { sessionId: string; lastEndedAt: number }>()
constructor(private repo: WritingSessionRepository) {}
recordActivity(bookId: string, chapterId: string | undefined, delta: number): void {
if (delta === 0) return
const now = new Date()
const nowIso = now.toISOString()
const cached = this.active.get(bookId)
const idle = !cached || now.getTime() - cached.lastEndedAt > IDLE_MS
if (idle) {
const id = randomUUID()
this.repo.insert({
id,
bookId,
chapterId,
startedAt: nowIso,
endedAt: nowIso,
wordDelta: delta,
date: localDateString()
})
this.active.set(bookId, { sessionId: id, lastEndedAt: now.getTime() })
} else {
this.repo.updateActivity(cached!.sessionId, nowIso, delta)
this.active.set(bookId, { sessionId: cached!.sessionId, lastEndedAt: now.getTime() })
}
}
endActive(bookId: string): void {
this.active.delete(bookId)
}
}
+10
View File
@@ -0,0 +1,10 @@
import { registerSyncHandlers } from './ipc/handlers/sync.handler'
import type { BookRegistryService } from './services/book-registry'
import type { GlobalSettingsService } from './services/global-settings'
export function bootstrapSyncHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
registerSyncHandlers(registry, settings)
}
+250 -3
View File
@@ -36,6 +36,8 @@ import type {
Snapshot,
SnapshotType,
UpdateChapterParams,
UpdateBookMetaParams,
ChapterTag,
Volume,
WordFreqResult,
KnowledgeEntry,
@@ -50,8 +52,32 @@ import type {
PomodoroState,
GoalNotificationPayload,
KnowledgeInjectionLog,
WritingAchievement
WritingAchievement,
CharacterGraphData,
CharacterRelationship,
WritingAnalyticsSummary,
ImportPreviewResult,
ImportExecuteParams,
ImportSplitMode
} from '../shared/types'
import type {
PackImportStrategy,
PackImportReport,
PackProgressEvent
} from '../shared/novel-pack'
import type { ExportMatrixParams } from '../shared/export-matrix'
import type { TimelineEntry, TimelineConflict, TimelineUpsertManualInput } from '../shared/timeline'
import type { CharacterArcNode } from '../shared/arc'
import type { ComplianceScanResult } from '../shared/compliance'
import type { UndoOperation, UndoStackEntry } from '../shared/undo'
import type {
SyncConfig,
SyncConfigureInput,
SyncProgressEvent,
SyncRunInput,
SyncRunResult
} from '../shared/sync'
import type { ExtensionManifestEntry } from '../shared/extension-api'
const electronAPI = {
window: {
@@ -74,8 +100,16 @@ const electronAPI = {
ipcRenderer.invoke(IPC.BOOK_OPEN, { bookId }),
updateMeta: (
bookId: string,
patch: { lastChapterId?: string | null; status?: BookMeta['status'] }
): Promise<IpcResult<BookMeta>> => ipcRenderer.invoke(IPC.BOOK_UPDATE_META, { bookId, ...patch })
patch: UpdateBookMetaParams
): Promise<IpcResult<BookMeta>> => ipcRenderer.invoke(IPC.BOOK_UPDATE_META, { bookId, ...patch }),
pickCover: (bookId: string): Promise<IpcResult<BookMeta | null>> =>
ipcRenderer.invoke(IPC.BOOK_PICK_COVER, { bookId })
},
bookPrefs: {
get: (bookId: string, key: string): Promise<IpcResult<string | null>> =>
ipcRenderer.invoke(IPC.BOOK_PREFS_GET, { bookId, key }),
set: (bookId: string, key: string, value: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.BOOK_PREFS_SET, { bookId, key, value })
},
volume: {
create: (bookId: string, name: string): Promise<IpcResult<Volume>> =>
@@ -114,6 +148,19 @@ const electronAPI = {
): Promise<IpcResult<Chapter>> =>
ipcRenderer.invoke(IPC.CHAPTER_SET_PUBLISH_STATUS, { bookId, chapterId, publishStatus })
},
chapterTag: {
list: (bookId: string, chapterId: string): Promise<IpcResult<ChapterTag[]>> =>
ipcRenderer.invoke(IPC.CHAPTER_TAG_LIST, { bookId, chapterId }),
set: (
bookId: string,
chapterId: string,
tag: string,
color?: string
): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.CHAPTER_TAG_SET, { bookId, chapterId, tag, color }),
remove: (bookId: string, chapterId: string, tag: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.CHAPTER_TAG_REMOVE, { bookId, chapterId, tag })
},
outline: {
list: (bookId: string): Promise<IpcResult<OutlineItem[]>> =>
ipcRenderer.invoke(IPC.OUTLINE_LIST, { bookId }),
@@ -271,6 +318,12 @@ const electronAPI = {
ipcRenderer.invoke(IPC.AI_SESSION_UPDATE, { bookId, sessionId, patch }),
sessionDelete: (bookId: string, sessionId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.AI_SESSION_DELETE, { bookId, sessionId }),
sessionExport: (
bookId: string,
sessionId: string,
format?: 'markdown' | 'txt'
): Promise<IpcResult<string | null>> =>
ipcRenderer.invoke(IPC.AI_SESSION_EXPORT, { bookId, sessionId, format }),
messageList: (bookId: string, sessionId: string): Promise<IpcResult<AiMessage[]>> =>
ipcRenderer.invoke(IPC.AI_MESSAGE_LIST, { bookId, sessionId }),
chat: (
@@ -513,6 +566,146 @@ const electronAPI = {
shouldShowOnOpen: (bookId: string): Promise<IpcResult<boolean>> =>
ipcRenderer.invoke(IPC.COCKPIT_SHOULD_SHOW, { bookId })
},
graph: {
getData: (
bookId: string,
filter?: 'all' | 'connected'
): Promise<IpcResult<CharacterGraphData>> =>
ipcRenderer.invoke(IPC.GRAPH_GET_DATA, { bookId, filter }),
upsertRelationship: (
bookId: string,
sourceId: string,
relationship: Partial<CharacterRelationship> & {
targetId: string
label: string
intimacy: number
}
): Promise<IpcResult<CharacterRelationship>> =>
ipcRenderer.invoke(IPC.GRAPH_UPSERT_RELATIONSHIP, { bookId, sourceId, relationship }),
deleteRelationship: (
bookId: string,
sourceId: string,
relationshipId: string
): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.GRAPH_DELETE_RELATIONSHIP, { bookId, sourceId, relationshipId })
},
analytics: {
getSummary: (bookId: string): Promise<IpcResult<WritingAnalyticsSummary>> =>
ipcRenderer.invoke(IPC.ANALYTICS_SUMMARY, { bookId })
},
import: {
pickFile: (): Promise<IpcResult<{ canceled: boolean; filePath?: string }>> =>
ipcRenderer.invoke(IPC.IMPORT_PICK_FILE),
preview: (
filePath: string,
splitMode: ImportSplitMode,
customRegex?: string
): Promise<IpcResult<ImportPreviewResult>> =>
ipcRenderer.invoke(IPC.IMPORT_PREVIEW, { filePath, splitMode, customRegex }),
execute: (params: ImportExecuteParams): Promise<IpcResult<string>> =>
ipcRenderer.invoke(IPC.IMPORT_EXECUTE, params)
},
export: {
formatChapter: (
bookId: string,
chapterId: string,
presetId: string
): Promise<IpcResult<string>> =>
ipcRenderer.invoke(IPC.EXPORT_FORMAT_CHAPTER, { bookId, chapterId, presetId }),
copyClipboard: (text: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.EXPORT_COPY_CLIPBOARD, { text }),
saveTxt: (text: string, defaultName: string): Promise<IpcResult<string | null>> =>
ipcRenderer.invoke(IPC.EXPORT_SAVE_TXT, { text, defaultName })
},
exportMatrix: {
export: (params: ExportMatrixParams): Promise<IpcResult<string | null>> =>
ipcRenderer.invoke(IPC.EXPORT_MATRIX_EXPORT, params)
},
timeline: {
list: (bookId: string): Promise<IpcResult<TimelineEntry[]>> =>
ipcRenderer.invoke(IPC.TIMELINE_LIST, { bookId }),
upsert: (
bookId: string,
input: TimelineUpsertManualInput
): Promise<IpcResult<TimelineEntry>> => ipcRenderer.invoke(IPC.TIMELINE_UPSERT, { bookId, input }),
delete: (bookId: string, id: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.TIMELINE_DELETE, { bookId, id }),
conflicts: (bookId: string): Promise<IpcResult<TimelineConflict[]>> =>
ipcRenderer.invoke(IPC.TIMELINE_CONFLICTS, { bookId })
},
arc: {
getForCharacter: (bookId: string, settingId: string): Promise<IpcResult<CharacterArcNode[]>> =>
ipcRenderer.invoke(IPC.ARC_GET_FOR_CHARACTER, { bookId, settingId })
},
compliance: {
getWords: (): Promise<IpcResult<string[]>> => ipcRenderer.invoke(IPC.COMPLIANCE_GET_WORDS),
setWords: (words: string[]): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.COMPLIANCE_SET_WORDS, { words }),
checkText: (text: string): Promise<IpcResult<ComplianceScanResult>> =>
ipcRenderer.invoke(IPC.COMPLIANCE_CHECK_TEXT, { text })
},
undo: {
push: (
bookId: string,
chapterId: string,
operation: UndoOperation
): Promise<IpcResult<UndoStackEntry>> =>
ipcRenderer.invoke(IPC.UNDO_PUSH, { bookId, chapterId, operation }),
list: (bookId: string, chapterId: string): Promise<IpcResult<UndoStackEntry[]>> =>
ipcRenderer.invoke(IPC.UNDO_LIST, { bookId, chapterId }),
apply: (bookId: string, entryId: number): Promise<IpcResult<UndoOperation>> =>
ipcRenderer.invoke(IPC.UNDO_APPLY, { bookId, entryId })
},
sync: {
status: (): Promise<IpcResult<SyncConfig | null>> => ipcRenderer.invoke(IPC.SYNC_STATUS),
configure: (input: SyncConfigureInput): Promise<IpcResult<SyncConfig>> =>
ipcRenderer.invoke(IPC.SYNC_CONFIGURE, input),
run: (input: SyncRunInput): Promise<IpcResult<SyncRunResult>> =>
ipcRenderer.invoke(IPC.SYNC_RUN, input),
pickFolder: (): Promise<IpcResult<string | null>> => ipcRenderer.invoke(IPC.SYNC_PICK_FOLDER)
},
update: {
check: (): Promise<IpcResult<{ version: string; releaseNotes?: string } | null>> =>
ipcRenderer.invoke(IPC.UPDATE_CHECK),
download: (): Promise<IpcResult<void>> => ipcRenderer.invoke(IPC.UPDATE_DOWNLOAD),
install: (): Promise<IpcResult<void>> => ipcRenderer.invoke(IPC.UPDATE_INSTALL)
},
log: {
hadCrash: (): Promise<IpcResult<boolean>> => ipcRenderer.invoke(IPC.LOG_HAD_CRASH),
clearCrash: (): Promise<IpcResult<void>> => ipcRenderer.invoke(IPC.LOG_CLEAR_CRASH),
sendReport: (note?: string): Promise<IpcResult<{ ok: true; loggedAt: string }>> =>
ipcRenderer.invoke(IPC.LOG_SEND_REPORT, { note }),
writeError: (message: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.LOG_WRITE_ERROR, { message })
},
extension: {
list: (): Promise<IpcResult<ExtensionManifestEntry[]>> => ipcRenderer.invoke(IPC.EXTENSION_LIST),
validate: (manifest: unknown): Promise<IpcResult<ExtensionManifestEntry>> =>
ipcRenderer.invoke(IPC.EXTENSION_VALIDATE, { manifest })
},
mcp: {
testConnection: (url: string): Promise<IpcResult<{ ok: boolean; message: string }>> =>
ipcRenderer.invoke(IPC.MCP_TEST_CONNECTION, { url })
},
pack: {
pickFile: (
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
): Promise<IpcResult<string | null>> => ipcRenderer.invoke(IPC.PACK_PICK_FILE, { mode }),
exportBook: (bookId: string, destPath: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.PACK_EXPORT_BOOK, { bookId, destPath }),
importBook: (filePath: string): Promise<IpcResult<string>> =>
ipcRenderer.invoke(IPC.PACK_IMPORT_BOOK, { filePath }),
exportAll: (destPath: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.PACK_EXPORT_ALL, { destPath }),
importAll: (
filePath: string,
strategy: PackImportStrategy
): Promise<IpcResult<PackImportReport>> =>
ipcRenderer.invoke(IPC.PACK_IMPORT_ALL, { filePath, strategy }),
estimateExportAll: (): Promise<IpcResult<{ bytes: number; needsConfirm: boolean }>> =>
ipcRenderer.invoke(IPC.PACK_ESTIMATE_EXPORT_ALL),
cancel: (): Promise<IpcResult<void>> => ipcRenderer.invoke(IPC.PACK_CANCEL)
},
bridge: {
get: (
bookId: string,
@@ -579,6 +772,60 @@ const electronAPI = {
}
ipcRenderer.on(IPC.GOAL_NOTIFICATION, handler)
return () => ipcRenderer.removeListener(IPC.GOAL_NOTIFICATION, handler)
},
onImportProgress: (
callback: (payload: { current: number; total: number }) => void
): (() => void) => {
const handler = (_event: IpcRendererEvent, payload: { current: number; total: number }) => {
callback(payload)
}
ipcRenderer.on(IPC.IMPORT_PROGRESS, handler)
return () => ipcRenderer.removeListener(IPC.IMPORT_PROGRESS, handler)
},
onPackProgress: (callback: (payload: PackProgressEvent) => void): (() => void) => {
const handler = (_event: IpcRendererEvent, payload: PackProgressEvent) => {
callback(payload)
}
ipcRenderer.on(IPC.PACK_PROGRESS, handler)
return () => ipcRenderer.removeListener(IPC.PACK_PROGRESS, handler)
},
onSyncProgress: (callback: (payload: SyncProgressEvent) => void): (() => void) => {
const handler = (_event: IpcRendererEvent, payload: SyncProgressEvent) => {
callback(payload)
}
ipcRenderer.on(IPC.SYNC_PROGRESS, handler)
return () => ipcRenderer.removeListener(IPC.SYNC_PROGRESS, handler)
},
onUpdateAvailable: (
callback: (payload: { version: string; releaseNotes?: string }) => void
): (() => void) => {
const handler = (
_event: IpcRendererEvent,
payload: { version: string; releaseNotes?: string }
) => {
callback(payload)
}
ipcRenderer.on(IPC.UPDATE_AVAILABLE, handler)
return () => ipcRenderer.removeListener(IPC.UPDATE_AVAILABLE, handler)
},
onUpdateProgress: (
callback: (payload: { percent: number; transferred: number; total: number }) => void
): (() => void) => {
const handler = (
_event: IpcRendererEvent,
payload: { percent: number; transferred: number; total: number }
) => {
callback(payload)
}
ipcRenderer.on(IPC.UPDATE_PROGRESS, handler)
return () => ipcRenderer.removeListener(IPC.UPDATE_PROGRESS, handler)
},
onUpdateDownloaded: (callback: (payload: { version: string }) => void): (() => void) => {
const handler = (_event: IpcRendererEvent, payload: { version: string }) => {
callback(payload)
}
ipcRenderer.on(IPC.UPDATE_DOWNLOADED, handler)
return () => ipcRenderer.removeListener(IPC.UPDATE_DOWNLOADED, handler)
}
}
+60 -6
View File
@@ -6,6 +6,7 @@ import './styles/themes.css'
import './styles/globals.css'
import './styles/layout.css'
import { TopBar } from '@renderer/components/layout/TopBar'
import { UpdateBanner } from '@renderer/components/settings/UpdateBanner'
import { HomePage } from '@renderer/components/home/HomePage'
import { EditorLayout } from '@renderer/components/layout/EditorLayout'
import { SettingsPage } from '@renderer/components/settings/SettingsPage'
@@ -17,17 +18,31 @@ import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { SearchModal } from '@renderer/components/search/SearchModal'
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal'
import { ImportBookModal } from '@renderer/components/import/ImportBookModal'
import { ExportAllModal } from '@renderer/components/export/ExportAllModal'
import { ReadModeOverlay } from '@renderer/components/read/ReadModeOverlay'
import { TimelineModal } from '@renderer/components/timeline/TimelineModal'
import { FocusModeOverlay } from '@renderer/components/focus/FocusModeOverlay'
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
import { useSearchStore } from '@renderer/stores/useSearchStore'
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
import { getEditorTextForTts } from '@renderer/lib/editor-commands'
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
import { isSpeaking, speakText, stopSpeaking } from '@renderer/lib/tts-controller'
import { useAiStore } from '@renderer/stores/useAiStore'
import { useExportStore } from '@renderer/stores/useExportStore'
import { CrashReportDialog } from '@renderer/components/settings/CrashReportDialog'
import { ipcCall } from '@renderer/lib/ipc-client'
function AppInner(): React.JSX.Element {
const { t } = useTranslation()
const view = useAppStore((s) => s.view)
const toast = useAppStore((s) => s.toast)
const focusMode = useAppStore((s) => s.focusMode)
const namingCheckOpen = useAppStore((s) => s.namingCheckOpen)
const setFocusMode = useAppStore((s) => s.setFocusMode)
const setNamingCheckOpen = useAppStore((s) => s.setNamingCheckOpen)
const showToast = useAppStore((s) => s.showToast)
const setView = useAppStore((s) => s.setView)
const { loaded, onboardingCompleted, load: loadSettings } = useSettingsStore()
@@ -35,6 +50,13 @@ function AppInner(): React.JSX.Element {
const { activeTabId, openTabs } = useTabStore()
const openBook = useBookStore((s) => s.openBook)
const [showOnboarding, setShowOnboarding] = useState(false)
const [crashDialogOpen, setCrashDialogOpen] = useState(false)
useEffect(() => {
void ipcCall(() => window.electronAPI.log.hadCrash()).then((had) => {
if (had) setCrashDialogOpen(true)
})
}, [])
useEffect(() => {
void (async () => {
@@ -57,8 +79,6 @@ function AppInner(): React.JSX.Element {
const openSearch = (): void => useSearchStore.getState().setOpen(true)
const openLandmarks = (): void => useAppStore.getState().setLandmarksModalOpen(true)
const openInspiration = (): void => {
if (useAppStore.getState().view !== 'editor') return
if (!useBookStore.getState().currentBookId) return
useAppStore.getState().setInspirationModalOpen(true)
}
const onInsertLandmark = (e: Event): void => {
@@ -148,31 +168,65 @@ function AppInner(): React.JSX.Element {
return
}
if (action === 'captureInspiration') {
if (view !== 'editor') return
if (!useBookStore.getState().currentBookId) return
useAppStore.getState().setInspirationModalOpen(true)
return
}
if (action === 'exportBook') {
if (view !== 'editor') return
const target = useEditStore.getState().target
if (target?.kind !== 'chapter') return
useExportStore.getState().openModal()
return
}
if (action === 'focusMode') {
if (view !== 'editor') return
setFocusMode(!useAppStore.getState().focusMode)
return
}
if (action === 'toggleTTS') {
if (view !== 'editor') return
if (isSpeaking()) {
stopSpeaking()
showToast(t('tts.stopped'))
return
}
const text = getEditorTextForTts()
if (!text.trim()) {
showToast(t('tts.noText'))
return
}
speakText(text)
showToast(t('tts.started'))
return
}
showToast(t('feature.comingSoon'))
})
return unsub
}, [view, activeTabId, openTabs, openBook, setView, showToast, t])
}, [view, activeTabId, openTabs, openBook, setView, setFocusMode, showToast, t])
const handleOnboardingComplete = (): void => {
setShowOnboarding(false)
}
return (
<div id="app">
<div id="app" className={focusMode ? 'focus-mode' : undefined} data-testid="app-root">
<TopBar />
<UpdateBanner />
<div id="main-area">
{view === 'home' && <HomePage />}
{view === 'editor' && <EditorLayout />}
{view === 'settings' && <SettingsPage />}
</div>
<FocusModeOverlay />
<ReadModeOverlay />
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
<SearchModal />
<InspirationModal />
<ImportBookModal />
<ExportAllModal />
<TimelineModal />
<CrashReportDialog open={crashDialogOpen} onClose={() => setCrashDialogOpen(false)} />
<NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} />
{toast && <div className="toast">{toast}</div>}
</div>
)
+28 -7
View File
@@ -6,12 +6,14 @@ import { useAppStore } from '@renderer/stores/useAppStore'
import { useAiStore } from '@renderer/stores/useAiStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { applySlashCommand } from '@renderer/lib/ai-slash-commands'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { insertToEditor } from '@renderer/lib/editor-commands'
import { ipcCall } from '@renderer/lib/ipc-client'
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
import { InteractivePanel } from '@renderer/components/ai/InteractivePanel'
import { AutoPanel } from '@renderer/components/ai/AutoPanel'
import { WizardPanel } from '@renderer/components/ai/WizardPanel'
import { AiSessionMenu } from '@renderer/components/ai/AiSessionMenu'
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
import { useAutoStore } from '@renderer/stores/useAutoStore'
import { useWizardStore } from '@renderer/stores/useWizardStore'
@@ -28,6 +30,7 @@ const QUICK_CMDS = ['/续写', '/扩写', '/润色', '/总结', '/纠错']
export function AiPanel(): React.JSX.Element {
const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast)
const customSlashCommands = useSettingsStore((s) => s.customSlashCommands)
const bookId = useBookStore((s) => s.currentBookId)
const sessions = useAiStore((s) => s.sessions)
const activeSessionId = useAiStore((s) => s.activeSessionId)
@@ -75,7 +78,7 @@ export function AiPanel(): React.JSX.Element {
useEffect(() => {
if (bookId) {
void loadSessions(bookId)
void loadSessions(bookId, true)
void checkGate(bookId)
void checkAutoGate(bookId)
void checkWizardGate(bookId)
@@ -89,7 +92,7 @@ export function AiPanel(): React.JSX.Element {
const handleSend = (): void => {
const text = input.trim()
if (!text || sending || disabled) return
const { display, prompt } = applySlashCommand(text, t)
const { display, prompt } = applySlashCommand(text, t, customSlashCommands)
setInput('')
void sendMessage(display, prompt !== display ? prompt : undefined)
}
@@ -176,6 +179,10 @@ export function AiPanel(): React.JSX.Element {
)
}
const activeSessions = sessions.filter((s) => !s.archived)
const archivedSessions = sessions.filter((s) => s.archived)
const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null
return (
<>
<div id="ai-panel" className="ai-panel" data-testid="ai-panel">
@@ -186,12 +193,26 @@ export function AiPanel(): React.JSX.Element {
onChange={(e) => void selectSession(e.target.value)}
>
{sessions.length === 0 && <option value="">{t('ai.noSession')}</option>}
{sessions.map((s) => (
<option key={s.id} value={s.id}>
{s.title || t('ai.untitledSession')}
</option>
))}
{activeSessions.length > 0 && (
<optgroup label={t('ai.session.active')}>
{activeSessions.map((s) => (
<option key={s.id} value={s.id}>
{s.title || t('ai.untitledSession')}
</option>
))}
</optgroup>
)}
{archivedSessions.length > 0 && (
<optgroup label={t('ai.session.archived')}>
{archivedSessions.map((s) => (
<option key={s.id} value={s.id}>
{s.title || t('ai.untitledSession')}
</option>
))}
</optgroup>
)}
</select>
<AiSessionMenu bookId={bookId} session={activeSession} />
<button
type="button"
className="btn"
@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { AiSession } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useAiStore } from '@renderer/stores/useAiStore'
interface AiSessionMenuProps {
bookId: string
session: AiSession | null
}
export function AiSessionMenu({ bookId, session }: AiSessionMenuProps): React.JSX.Element | null {
const { t } = useTranslation()
const loadSessions = useAiStore((s) => s.loadSessions)
const selectSession = useAiStore((s) => s.selectSession)
const [open, setOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
useEffect(() => {
if (!open) return
const close = (): void => {
setOpen(false)
setExportOpen(false)
}
window.addEventListener('click', close)
return () => window.removeEventListener('click', close)
}, [open])
if (!session) return null
const handleRename = async (): Promise<void> => {
setOpen(false)
const title = window.prompt(t('ai.session.renamePrompt'), session.title)
if (!title?.trim() || title.trim() === session.title) return
await ipcCall(() =>
window.electronAPI.ai.sessionUpdate(bookId, session.id, { title: title.trim() })
)
await loadSessions(bookId, true)
}
const handleArchiveToggle = async (): Promise<void> => {
setOpen(false)
await ipcCall(() =>
window.electronAPI.ai.sessionUpdate(bookId, session.id, { archived: !session.archived })
)
await loadSessions(bookId, true)
}
const handleExport = async (format: 'markdown' | 'txt'): Promise<void> => {
setOpen(false)
setExportOpen(false)
await ipcCall(() => window.electronAPI.ai.sessionExport(bookId, session.id, format))
}
const handleDelete = async (): Promise<void> => {
setOpen(false)
if (!window.confirm(t('ai.session.deleteConfirm', { title: session.title || t('ai.untitledSession') }))) {
return
}
await ipcCall(() => window.electronAPI.ai.sessionDelete(bookId, session.id))
await loadSessions(bookId, true)
const next = useAiStore.getState().sessions[0]
if (next) await selectSession(next.id)
}
return (
<div className="ai-session-menu-wrap">
<button
type="button"
className="btn ai-session-menu-btn"
data-testid="ai-session-menu-btn"
onClick={(e) => {
e.stopPropagation()
setOpen((v) => !v)
}}
>
</button>
{open && (
<div className="context-menu ai-session-menu" onClick={(e) => e.stopPropagation()}>
<button type="button" onClick={() => void handleRename()}>
{t('ai.session.rename')}
</button>
<button type="button" onClick={() => void handleArchiveToggle()}>
{session.archived ? t('ai.session.unarchive') : t('ai.session.archive')}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setExportOpen((v) => !v)
}}
>
{t('ai.session.export')}
</button>
{exportOpen && (
<div className="ai-session-export-submenu">
<button type="button" onClick={() => void handleExport('markdown')}>
{t('ai.session.exportMd')}
</button>
<button type="button" onClick={() => void handleExport('txt')}>
{t('ai.session.exportTxt')}
</button>
</div>
)}
<button type="button" className="danger" onClick={() => void handleDelete()}>
{t('ai.session.delete')}
</button>
</div>
)}
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More