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) # 笔临 (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 章节知识自动抽取、合并审核、高置信度批量采纳(P5.2)
- 知识库 AI 上下文:半自动推荐 + 勾选注入四种写作模式(P6) - 知识库 AI 上下文:半自动推荐 + 勾选注入四种写作模式(P6)
- 驾驶舱:全书/本卷字数、存稿缓冲、伏笔摘要、快速入口(P5) - 驾驶舱:全书/本卷字数、存稿缓冲、伏笔摘要、快速入口(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: { build: {
rollupOptions: { rollupOptions: {
input: { 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", "name": "bilin",
"version": "0.9.0", "version": "2.0.0",
"description": "笔临 - 长篇创作智能协作平台", "description": "笔临 - 长篇创作智能协作平台",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"type": "module", "type": "module",
@@ -28,10 +28,19 @@
"@tiptap/extension-underline": "^3.27.1", "@tiptap/extension-underline": "^3.27.1",
"@tiptap/react": "^3.27.1", "@tiptap/react": "^3.27.1",
"@tiptap/starter-kit": "^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", "diff-match-patch": "^1.0.5",
"docx": "^9.7.1",
"electron": "^36.9.0", "electron": "^36.9.0",
"electron-log": "^5.4.4",
"electron-updater": "^6.8.9",
"extract-zip": "^2.0.1",
"i18next": "^24.2.3", "i18next": "^24.2.3",
"jotai": "^2.12.1", "jotai": "^2.12.1",
"mammoth": "^1.12.0",
"marked": "^15.0.12",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^15.4.1", "react-i18next": "^15.4.1",
@@ -40,7 +49,11 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.51.0", "@playwright/test": "^1.51.0",
"@types/archiver": "^8.0.0",
"@types/cytoscape": "^3.21.9",
"@types/diff-match-patch": "^1.0.36", "@types/diff-match-patch": "^1.0.36",
"@types/extract-zip": "^2.0.0",
"@types/marked": "^6.0.0",
"@types/node": "^22.13.0", "@types/node": "^22.13.0",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
+261 -1
View File
@@ -221,11 +221,39 @@
"cockpit.resumeEdit": "Resume writing", "cockpit.resumeEdit": "Resume writing",
"cockpit.bridgeCheck": "Chapter bridge check", "cockpit.bridgeCheck": "Chapter bridge check",
"cockpit.forgottenForeshadow": "Forgotten foreshadows", "cockpit.forgottenForeshadow": "Forgotten foreshadows",
"cockpit.todayOutlineTasks": "Today's outline tasks",
"cockpit.outlineTargetWords": "Target {{count}} words",
"cockpit.todayProgress": "Today {{current}} / {{goal}} words", "cockpit.todayProgress": "Today {{current}} / {{goal}} words",
"cockpit.streak": "{{days}}-day streak", "cockpit.streak": "{{days}}-day streak",
"cockpit.heatmap": "Writing heatmap", "cockpit.heatmap": "Writing heatmap",
"cockpit.achievements": "Writing achievements", "cockpit.achievements": "Writing achievements",
"cockpit.pomodoroToday": "{{count}} pomodoros today", "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.start": "Start pomodoro",
"pomodoro.pause": "Pause", "pomodoro.pause": "Pause",
"pomodoro.resume": "Resume", "pomodoro.resume": "Resume",
@@ -246,6 +274,30 @@
"settings.pomodoroDuration": "Pomodoro duration (minutes)", "settings.pomodoroDuration": "Pomodoro duration (minutes)",
"settings.systemNotifications": "Enable system notifications", "settings.systemNotifications": "Enable system notifications",
"settings.goalNotifications": "Enable goal 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.title": "Chapter bridge",
"bridge.loading": "Analyzing…", "bridge.loading": "Analyzing…",
"bridge.previousTail": "Previous chapter ending", "bridge.previousTail": "Previous chapter ending",
@@ -374,5 +426,213 @@
"landmark.created": "Landmark inserted", "landmark.created": "Landmark inserted",
"landmark.chapterOnly": "Insert landmarks in a chapter", "landmark.chapterOnly": "Insert landmarks in a chapter",
"wordfreq.hint": "Top 100 words in current scope", "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.resumeEdit": "继续写作",
"cockpit.bridgeCheck": "章间衔接检查", "cockpit.bridgeCheck": "章间衔接检查",
"cockpit.forgottenForeshadow": "查看遗忘伏笔", "cockpit.forgottenForeshadow": "查看遗忘伏笔",
"cockpit.todayOutlineTasks": "今日大纲任务",
"cockpit.outlineTargetWords": "目标 {{count}} 字",
"cockpit.todayProgress": "今日 {{current}} / {{goal}} 字", "cockpit.todayProgress": "今日 {{current}} / {{goal}} 字",
"cockpit.streak": "连更 {{days}} 天", "cockpit.streak": "连更 {{days}} 天",
"cockpit.heatmap": "写作热力图", "cockpit.heatmap": "写作热力图",
"cockpit.achievements": "写作成就", "cockpit.achievements": "写作成就",
"cockpit.pomodoroToday": "今日番茄 {{count}} 次", "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.start": "开始番茄钟",
"pomodoro.pause": "暂停", "pomodoro.pause": "暂停",
"pomodoro.resume": "继续", "pomodoro.resume": "继续",
@@ -246,6 +274,30 @@
"settings.pomodoroDuration": "番茄钟时长(分钟)", "settings.pomodoroDuration": "番茄钟时长(分钟)",
"settings.systemNotifications": "启用系统通知", "settings.systemNotifications": "启用系统通知",
"settings.goalNotifications": "启用目标达成提醒", "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.title": "章间衔接",
"bridge.loading": "分析中…", "bridge.loading": "分析中…",
"bridge.previousTail": "上一章末尾", "bridge.previousTail": "上一章末尾",
@@ -374,5 +426,213 @@
"landmark.created": "地标已插入", "landmark.created": "地标已插入",
"landmark.chapterOnly": "请在章节中插入地标", "landmark.chapterOnly": "请在章节中插入地标",
"wordfreq.hint": "当前范围词频 Top 100", "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 schemaV6 from './schema-v6.sql?raw'
import schemaV7 from './schema-v7.sql?raw' import schemaV7 from './schema-v7.sql?raw'
import schemaV8 from './schema-v8.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 { function tryAlter(db: SqliteDb, sql: string): void {
try { try {
@@ -88,5 +92,23 @@ export function migrate(db: SqliteDb): void {
if (current < 8) { if (current < 8) {
db.exec(schemaV8) db.exec(schemaV8)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(8, 'P5.3 P6.1 goals injection') 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, sortOrder: row.sort_order as number,
cursorOffset: (row.cursor_offset as number) ?? 0, cursorOffset: (row.cursor_offset as number) ?? 0,
origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin, 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 status: ChapterStatus
cursorOffset: number cursorOffset: number
publishStatus: PublishStatus publishStatus: PublishStatus
povCharacterId: string | null
summary: string
storyTime: string | null
publishedAt: string | null
wordCountThreshold: number | null
}> }>
): Chapter { ): Chapter {
const existing = this.get(id) const existing = this.get(id)
@@ -73,12 +83,16 @@ export class ChapterRepository {
const content = patch.content ?? existing.content const content = patch.content ?? existing.content
const wordCount = countWords(content) const wordCount = countWords(content)
const povCharacterId =
patch.povCharacterId !== undefined ? patch.povCharacterId : existing.povCharacterId ?? null
this.db this.db
.prepare( .prepare(
`UPDATE chapters SET `UPDATE chapters SET
title = ?, content = ?, status = ?, cursor_offset = ?, 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 = ?` WHERE id = ?`
) )
.run( .run(
@@ -87,14 +101,25 @@ export class ChapterRepository {
patch.status ?? existing.status, patch.status ?? existing.status,
patch.cursorOffset ?? existing.cursorOffset, patch.cursorOffset ?? existing.cursorOffset,
patch.publishStatus ?? existing.publishStatus ?? 'draft', patch.publishStatus ?? existing.publishStatus ?? 'draft',
povCharacterId,
wordCount, 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 id
) )
return this.get(id)! return this.get(id)!
} }
setPublishStatus(id: string, publishStatus: PublishStatus): Chapter { 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 { countByPublishStatus(status: PublishStatus): number {
@@ -22,4 +22,19 @@ export class PomodoroDailyRepository {
.get(date, bookId) as { completed_count: number } | undefined .get(date, bookId) as { completed_count: number } | undefined
return row?.completed_count ?? 0 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, completed_count INTEGER NOT NULL DEFAULT 0,
total_words INTEGER NOT NULL DEFAULT 0, total_words INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (date, book_id) 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) getNetworkMonitor()?.start(() => mainWindow)
} }
app.whenReady().then(() => { app.whenReady().then(async () => {
registerIpc() 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) => { ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize() BrowserWindow.fromWebContents(event.sender)?.minimize()
+44 -1
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'crypto' 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 { IPC } from '../../../shared/ipc-channels'
import type { AiConfig, AiContextBinding, NamingConflict } from '../../../shared/types' import type { AiConfig, AiContextBinding, NamingConflict } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry' import { BookRegistryService } from '../../services/book-registry'
@@ -19,6 +20,15 @@ import { wrap } from '../result'
const activeChats = new Map<string, AbortController>() 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( export function registerAiHandlers(
registry: BookRegistryService, registry: BookRegistryService,
settings: GlobalSettingsService, settings: GlobalSettingsService,
@@ -67,6 +77,39 @@ export function registerAiHandlers(
wrap(() => registry.getAiSessionRepo(bookId).listMessages(sessionId)) 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( ipcMain.handle(
IPC.AI_BUILD_CONTEXT, IPC.AI_BUILD_CONTEXT,
(_e, { bookId, binding }: { bookId: string; binding: AiContextBinding }) => (_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 { trackerFor } from '../../services/chapter-writing-tracker.service'
import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner' import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner'
import type { WritingLogService } from '../../services/writing-log.service' import type { WritingLogService } from '../../services/writing-log.service'
import type { WritingSessionService } from '../../services/writing-session.service'
import { InteractiveRepository } from '../../db/repositories/interactive.repo' import { InteractiveRepository } from '../../db/repositories/interactive.repo'
import { wrap } from '../result' import { wrap } from '../result'
@@ -38,7 +39,8 @@ export function registerAutoHandlers(
registry: BookRegistryService, registry: BookRegistryService,
settings: GlobalSettingsService, settings: GlobalSettingsService,
aiClient: AiClientService, aiClient: AiClientService,
writingLogs: WritingLogService writingLogs: WritingLogService,
writingSessions: WritingSessionService
): void { ): void {
ipcMain.handle(IPC.AUTO_CHECK_GATE, (_e, { bookId }: { bookId: string }) => ipcMain.handle(IPC.AUTO_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
@@ -217,7 +219,10 @@ export function registerAutoHandlers(
wrap(() => { wrap(() => {
const result = serviceFor(registry, bookId, aiClient).finishChapter(flowId, volumeId, title) const result = serviceFor(registry, bookId, aiClient).finishChapter(flowId, volumeId, title)
const ch = registry.getChapterRepo(bookId).get(result.chapterId)! 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) scheduleChapterExtraction(registry, bookId, result.chapterId, aiClient, settings)
return result 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 { 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 { BookRegistryService } from '../../services/book-registry'
import type { WritingSessionService } from '../../services/writing-session.service'
import { wrap } from '../result' 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_LIST, () => wrap(() => registry.list()))
ipcMain.handle(IPC.BOOK_CREATE, (_event, params: CreateBookParams) => 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 }) => 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( ipcMain.handle(
IPC.BOOK_UPDATE_META, IPC.BOOK_UPDATE_META,
( (_event, { bookId, ...patch }: { bookId: string } & UpdateBookMetaParams) =>
_event, wrap(() => registry.updateMeta(bookId, patch))
{ )
bookId,
...patch ipcMain.handle(IPC.BOOK_PICK_COVER, (_event, { bookId }: { bookId: string }) =>
}: { wrap(async () => {
bookId: string const { canceled, filePaths } = await dialog.showOpenDialog({
lastChapterId?: string | null filters: [{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'webp'] }],
lastOpenedAt?: string properties: ['openFile']
status?: BookMeta['status'] })
} if (canceled || !filePaths[0]) return null
) => wrap(() => registry.updateMeta(bookId, patch)) 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 { BookRegistryService } from '../../services/book-registry'
import { trackerFor } from '../../services/chapter-writing-tracker.service' import { trackerFor } from '../../services/chapter-writing-tracker.service'
import type { WritingLogService } from '../../services/writing-log.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 { ftsSync } from '../../services/fts-sync.service'
import { wrap } from '../result' import { wrap } from '../result'
export function registerChapterHandlers( export function registerChapterHandlers(
registry: BookRegistryService, registry: BookRegistryService,
writingLogs: WritingLogService writingLogs: WritingLogService,
writingSessions: WritingSessionService
): void { ): void {
ipcMain.handle( ipcMain.handle(
IPC.VOLUME_CREATE, IPC.VOLUME_CREATE,
@@ -72,7 +74,13 @@ export function registerChapterHandlers(
title, title,
content, content,
status, status,
cursorOffset cursorOffset,
povCharacterId,
summary,
storyTime,
wordCountThreshold,
publishStatus,
publishedAt
}: { }: {
bookId: string bookId: string
chapterId: string chapterId: string
@@ -80,6 +88,12 @@ export function registerChapterHandlers(
content?: string content?: string
status?: ChapterStatus status?: ChapterStatus
cursorOffset?: number cursorOffset?: number
povCharacterId?: string | null
summary?: string
storyTime?: string | null
wordCountThreshold?: number | null
publishStatus?: PublishStatus
publishedAt?: string | null
} }
) => ) =>
wrap(() => { wrap(() => {
@@ -87,7 +101,13 @@ export function registerChapterHandlers(
title, title,
content, content,
status, status,
cursorOffset cursorOffset,
povCharacterId,
summary,
storyTime,
wordCountThreshold,
publishStatus,
publishedAt
}) })
ftsSync.upsert( ftsSync.upsert(
registry.getDb(bookId), registry.getDb(bookId),
@@ -96,7 +116,10 @@ export function registerChapterHandlers(
chapter.title, chapter.title,
chapter.content chapter.content
) )
trackerFor(registry, bookId, writingLogs).recordDelta(chapterId, chapter.wordCount) trackerFor(registry, bookId, writingLogs, writingSessions).recordDelta(
chapterId,
chapter.wordCount
)
registry.updateMeta(bookId, { lastChapterId: chapterId }) registry.updateMeta(bookId, { lastChapterId: chapterId })
return chapter return chapter
}) })
@@ -138,4 +161,34 @@ export function registerChapterHandlers(
{ bookId, chapterId, publishStatus }: { bookId: string; chapterId: string; publishStatus: PublishStatus } { bookId, chapterId, publishStatus }: { bookId: string; chapterId: string; publishStatus: PublishStatus }
) => wrap(() => registry.getChapterRepo(bookId).setPublishStatus(chapterId, 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 { trackerFor } from '../../services/chapter-writing-tracker.service'
import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner' import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner'
import type { WritingLogService } from '../../services/writing-log.service' import type { WritingLogService } from '../../services/writing-log.service'
import type { WritingSessionService } from '../../services/writing-session.service'
import { wrap } from '../result' import { wrap } from '../result'
const activeInteractive = new Map<string, AbortController>() const activeInteractive = new Map<string, AbortController>()
@@ -29,7 +30,8 @@ export function registerInteractiveHandlers(
registry: BookRegistryService, registry: BookRegistryService,
settings: GlobalSettingsService, settings: GlobalSettingsService,
aiClient: AiClientService, aiClient: AiClientService,
writingLogs: WritingLogService writingLogs: WritingLogService,
writingSessions: WritingSessionService
): void { ): void {
ipcMain.handle(IPC.INTERACTIVE_CHECK_GATE, (_e, { bookId }: { bookId: string }) => ipcMain.handle(IPC.INTERACTIVE_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
wrap(() => checkInteractiveGate(registry.getDb(bookId))) wrap(() => checkInteractiveGate(registry.getDb(bookId)))
@@ -208,7 +210,10 @@ export function registerInteractiveHandlers(
wrap(() => { wrap(() => {
const result = serviceFor(registry, bookId, aiClient).finishChapter(flowId, volumeId, title) const result = serviceFor(registry, bookId, aiClient).finishChapter(flowId, volumeId, title)
const ch = registry.getChapterRepo(bookId).get(result.chapterId)! 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) scheduleChapterExtraction(registry, bookId, result.chapterId, aiClient, settings)
return result 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 { GoalNotificationService } from '../services/goal-notification.service'
import { AchievementService } from '../services/achievement.service' import { AchievementService } from '../services/achievement.service'
import { PomodoroService } from '../services/pomodoro.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 { registerSettingsHandlers } from './handlers/settings.handler'
import { registerBookHandlers } from './handlers/book.handler' import { registerBookHandlers } from './handlers/book.handler'
import { registerBookPrefsHandlers } from './handlers/book-prefs.handler'
import { registerChapterHandlers } from './handlers/chapter.handler' import { registerChapterHandlers } from './handlers/chapter.handler'
import { registerShortcutHandlers } from './handlers/shortcut.handler' import { registerShortcutHandlers } from './handlers/shortcut.handler'
import { registerOutlineHandlers } from './handlers/outline.handler' import { registerOutlineHandlers } from './handlers/outline.handler'
@@ -29,7 +32,17 @@ import { registerCockpitHandlers } from './handlers/cockpit.handler'
import { registerWritingHandlers } from './handlers/writing.handler' import { registerWritingHandlers } from './handlers/writing.handler'
import { registerPomodoroHandlers } from './handlers/pomodoro.handler' import { registerPomodoroHandlers } from './handlers/pomodoro.handler'
import { registerAchievementHandlers } from './handlers/achievement.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 { 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 { AiClientService } from '../services/ai-client.service'
import { NetworkMonitorService } from '../services/network-monitor.service' import { NetworkMonitorService } from '../services/network-monitor.service'
@@ -37,15 +50,28 @@ let shortcutManager: ShortcutManager | null = null
let snapshotService: SnapshotService | null = null let snapshotService: SnapshotService | null = null
let networkMonitor: NetworkMonitorService | 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 userData = app.getPath('userData')
const settings = new GlobalSettingsService(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 books = new BookRegistryService(userData)
const writingLogRepo = new WritingLogRepository(userData) const writingLogRepo = new WritingLogRepository(userData)
const writingLogs = new WritingLogService(writingLogRepo, settings) const writingLogs = new WritingLogService(writingLogRepo, settings)
const milestoneRepo = new WritingMilestoneRepository(writingLogRepo.getDb()) const milestoneRepo = new WritingMilestoneRepository(writingLogRepo.getDb())
const pomodoroDailyRepo = new PomodoroDailyRepository(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 goalNotify = new GoalNotificationService(settings)
const achievementService = new AchievementService( const achievementService = new AchievementService(
milestoneRepo, milestoneRepo,
@@ -61,8 +87,9 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
networkMonitor = new NetworkMonitorService() networkMonitor = new NetworkMonitorService()
registerSettingsHandlers(settings) registerSettingsHandlers(settings)
registerBookHandlers(books) registerBookHandlers(books, writingSessions)
registerChapterHandlers(books, writingLogs) registerBookPrefsHandlers(books)
registerChapterHandlers(books, writingLogs, writingSessions)
registerShortcutHandlers(shortcutManager) registerShortcutHandlers(shortcutManager)
registerOutlineHandlers(books) registerOutlineHandlers(books)
registerSettingHandlers(books) registerSettingHandlers(books)
@@ -72,17 +99,27 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
registerSearchHandlers(books, settings) registerSearchHandlers(books, settings)
const aiClient = new AiClientService(() => settings.get().aiConfig) const aiClient = new AiClientService(() => settings.get().aiConfig)
registerAiHandlers(books, settings, aiClient) registerAiHandlers(books, settings, aiClient)
registerInteractiveHandlers(books, settings, aiClient, writingLogs) registerInteractiveHandlers(books, settings, aiClient, writingLogs, writingSessions)
registerAutoHandlers(books, settings, aiClient, writingLogs) registerAutoHandlers(books, settings, aiClient, writingLogs, writingSessions)
registerWizardHandlers(books, settings, aiClient) registerWizardHandlers(books, settings, aiClient)
registerKnowledgeHandlers(books, settings, aiClient) registerKnowledgeHandlers(books, settings, aiClient)
registerCockpitHandlers(books, settings, writingLogs, achievementService, pomodoroDailyRepo) registerCockpitHandlers(books, settings, writingLogs, achievementService, pomodoroDailyRepo)
registerWritingHandlers(writingLogs) registerWritingHandlers(writingLogs)
registerPomodoroHandlers(pomodoro) registerPomodoroHandlers(pomodoro)
registerAchievementHandlers(achievementService) registerAchievementHandlers(achievementService)
registerGraphHandlers(books)
registerAnalyticsHandlers(books, writingLogs, writingSessionRepo, pomodoroDailyRepo)
registerBridgeHandlers(books, aiClient) 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 { 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 { join } from 'path'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { openBookDb } from '../db/connection' 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 { InteractiveRepository } from '../db/repositories/interactive.repo'
import { SnapshotRepository } from '../db/repositories/snapshot.repo' import { SnapshotRepository } from '../db/repositories/snapshot.repo'
import { BookmarkRepository } from '../db/repositories/bookmark.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 { interface RegistryFile {
books: BookMeta[] books: BookMeta[]
@@ -49,6 +50,10 @@ export class BookRegistryService {
return this.readRegistry().books.find((b) => b.id === bookId) ?? null return this.readRegistry().books.find((b) => b.id === bookId) ?? null
} }
getUserDataDir(): string {
return this.userDataDir
}
private dbPath(bookId: string): string { private dbPath(bookId: string): string {
return join(this.booksDir, `${bookId}.sqlite`) return join(this.booksDir, `${bookId}.sqlite`)
} }
@@ -90,7 +95,45 @@ export class BookRegistryService {
this.writeRegistry(registry) 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 registry = this.readRegistry()
const idx = registry.books.findIndex((b) => b.id === bookId) const idx = registry.books.findIndex((b) => b.id === bookId)
if (idx === -1) throw new Error('Book not found') if (idx === -1) throw new Error('Book not found')
@@ -162,6 +205,10 @@ export class BookRegistryService {
getBookmarkRepo(bookId: string): BookmarkRepository { getBookmarkRepo(bookId: string): BookmarkRepository {
return new BookmarkRepository(this.getDb(bookId)) return new BookmarkRepository(this.getDb(bookId))
} }
getChapterTagRepo(bookId: string): ChapterTagRepository {
return new ChapterTagRepository(this.getDb(bookId))
}
} }
export function aggregateWordCounts(chapters: Chapter[], volumes: Volume[]): { 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 { ChapterWritingSnapshotRepository } from '../db/repositories/chapter-writing-snapshot.repo'
import type { BookRegistryService } from './book-registry' import type { BookRegistryService } from './book-registry'
import type { WritingLogService } from './writing-log.service' import type { WritingLogService } from './writing-log.service'
import type { WritingSessionService } from './writing-session.service'
export function trackerFor( export function trackerFor(
registry: BookRegistryService, registry: BookRegistryService,
bookId: string, bookId: string,
writingLogs: WritingLogService writingLogs: WritingLogService,
sessions: WritingSessionService
): ChapterWritingTracker { ): ChapterWritingTracker {
return new ChapterWritingTracker(registry.getDb(bookId), bookId, writingLogs) return new ChapterWritingTracker(registry.getDb(bookId), bookId, writingLogs, sessions)
} }
export class ChapterWritingTracker { export class ChapterWritingTracker {
@@ -17,7 +19,8 @@ export class ChapterWritingTracker {
constructor( constructor(
bookDb: SqliteDb, bookDb: SqliteDb,
private bookId: string, private bookId: string,
private writingLogs: WritingLogService private writingLogs: WritingLogService,
private sessions: WritingSessionService
) { ) {
this.snapRepo = new ChapterWritingSnapshotRepository(bookDb) this.snapRepo = new ChapterWritingSnapshotRepository(bookDb)
} }
@@ -26,7 +29,10 @@ export class ChapterWritingTracker {
const snap = this.snapRepo.get(chapterId) const snap = this.snapRepo.get(chapterId)
const logged = snap?.loggedWordCount ?? 0 const logged = snap?.loggedWordCount ?? 0
const delta = newWordCount - logged 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) this.snapRepo.upsert(chapterId, newWordCount, snap?.finishSupplemented ?? false)
} }
@@ -34,7 +40,10 @@ export class ChapterWritingTracker {
const snap = this.snapRepo.get(chapterId) const snap = this.snapRepo.get(chapterId)
const logged = snap?.loggedWordCount ?? 0 const logged = snap?.loggedWordCount ?? 0
const gap = wordCount - logged 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) 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 { 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 { 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, knowledgeExtractConfidenceThreshold: 0.8,
pomodoroDurationMinutes: 25, pomodoroDurationMinutes: 25,
enableSystemNotifications: false, 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, enableSystemNotifications: raw.enableSystemNotifications ?? false,
enableGoalNotifications: raw.enableGoalNotifications ?? true, enableGoalNotifications: raw.enableGoalNotifications ?? true,
dailyGoalNotifiedDate: raw.dailyGoalNotifiedDate, 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 }, shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig } 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 } 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 { private calcStreak(bookId: string, goal: number): number {
let streak = 0 let streak = 0
let date = addDays(localDateString(), -1) 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, Snapshot,
SnapshotType, SnapshotType,
UpdateChapterParams, UpdateChapterParams,
UpdateBookMetaParams,
ChapterTag,
Volume, Volume,
WordFreqResult, WordFreqResult,
KnowledgeEntry, KnowledgeEntry,
@@ -50,8 +52,32 @@ import type {
PomodoroState, PomodoroState,
GoalNotificationPayload, GoalNotificationPayload,
KnowledgeInjectionLog, KnowledgeInjectionLog,
WritingAchievement WritingAchievement,
CharacterGraphData,
CharacterRelationship,
WritingAnalyticsSummary,
ImportPreviewResult,
ImportExecuteParams,
ImportSplitMode
} from '../shared/types' } 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 = { const electronAPI = {
window: { window: {
@@ -74,8 +100,16 @@ const electronAPI = {
ipcRenderer.invoke(IPC.BOOK_OPEN, { bookId }), ipcRenderer.invoke(IPC.BOOK_OPEN, { bookId }),
updateMeta: ( updateMeta: (
bookId: string, bookId: string,
patch: { lastChapterId?: string | null; status?: BookMeta['status'] } patch: UpdateBookMetaParams
): Promise<IpcResult<BookMeta>> => ipcRenderer.invoke(IPC.BOOK_UPDATE_META, { bookId, ...patch }) ): 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: { volume: {
create: (bookId: string, name: string): Promise<IpcResult<Volume>> => create: (bookId: string, name: string): Promise<IpcResult<Volume>> =>
@@ -114,6 +148,19 @@ const electronAPI = {
): Promise<IpcResult<Chapter>> => ): Promise<IpcResult<Chapter>> =>
ipcRenderer.invoke(IPC.CHAPTER_SET_PUBLISH_STATUS, { bookId, chapterId, publishStatus }) 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: { outline: {
list: (bookId: string): Promise<IpcResult<OutlineItem[]>> => list: (bookId: string): Promise<IpcResult<OutlineItem[]>> =>
ipcRenderer.invoke(IPC.OUTLINE_LIST, { bookId }), ipcRenderer.invoke(IPC.OUTLINE_LIST, { bookId }),
@@ -271,6 +318,12 @@ const electronAPI = {
ipcRenderer.invoke(IPC.AI_SESSION_UPDATE, { bookId, sessionId, patch }), ipcRenderer.invoke(IPC.AI_SESSION_UPDATE, { bookId, sessionId, patch }),
sessionDelete: (bookId: string, sessionId: string): Promise<IpcResult<void>> => sessionDelete: (bookId: string, sessionId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.AI_SESSION_DELETE, { bookId, sessionId }), 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[]>> => messageList: (bookId: string, sessionId: string): Promise<IpcResult<AiMessage[]>> =>
ipcRenderer.invoke(IPC.AI_MESSAGE_LIST, { bookId, sessionId }), ipcRenderer.invoke(IPC.AI_MESSAGE_LIST, { bookId, sessionId }),
chat: ( chat: (
@@ -513,6 +566,146 @@ const electronAPI = {
shouldShowOnOpen: (bookId: string): Promise<IpcResult<boolean>> => shouldShowOnOpen: (bookId: string): Promise<IpcResult<boolean>> =>
ipcRenderer.invoke(IPC.COCKPIT_SHOULD_SHOW, { bookId }) 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: { bridge: {
get: ( get: (
bookId: string, bookId: string,
@@ -579,6 +772,60 @@ const electronAPI = {
} }
ipcRenderer.on(IPC.GOAL_NOTIFICATION, handler) ipcRenderer.on(IPC.GOAL_NOTIFICATION, handler)
return () => ipcRenderer.removeListener(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/globals.css'
import './styles/layout.css' import './styles/layout.css'
import { TopBar } from '@renderer/components/layout/TopBar' import { TopBar } from '@renderer/components/layout/TopBar'
import { UpdateBanner } from '@renderer/components/settings/UpdateBanner'
import { HomePage } from '@renderer/components/home/HomePage' import { HomePage } from '@renderer/components/home/HomePage'
import { EditorLayout } from '@renderer/components/layout/EditorLayout' import { EditorLayout } from '@renderer/components/layout/EditorLayout'
import { SettingsPage } from '@renderer/components/settings/SettingsPage' import { SettingsPage } from '@renderer/components/settings/SettingsPage'
@@ -17,17 +18,31 @@ import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore' import { useEditStore } from '@renderer/stores/useEditStore'
import { SearchModal } from '@renderer/components/search/SearchModal' import { SearchModal } from '@renderer/components/search/SearchModal'
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal' 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 { useSearchStore } from '@renderer/stores/useSearchStore'
import { useReferenceStore } from '@renderer/stores/useReferenceStore' import { useReferenceStore } from '@renderer/stores/useReferenceStore'
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor' import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
import { getEditorTextForTts } from '@renderer/lib/editor-commands'
import { insertLandmarkInEditor } 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 { 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' import { ipcCall } from '@renderer/lib/ipc-client'
function AppInner(): React.JSX.Element { function AppInner(): React.JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const view = useAppStore((s) => s.view) const view = useAppStore((s) => s.view)
const toast = useAppStore((s) => s.toast) 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 showToast = useAppStore((s) => s.showToast)
const setView = useAppStore((s) => s.setView) const setView = useAppStore((s) => s.setView)
const { loaded, onboardingCompleted, load: loadSettings } = useSettingsStore() const { loaded, onboardingCompleted, load: loadSettings } = useSettingsStore()
@@ -35,6 +50,13 @@ function AppInner(): React.JSX.Element {
const { activeTabId, openTabs } = useTabStore() const { activeTabId, openTabs } = useTabStore()
const openBook = useBookStore((s) => s.openBook) const openBook = useBookStore((s) => s.openBook)
const [showOnboarding, setShowOnboarding] = useState(false) const [showOnboarding, setShowOnboarding] = useState(false)
const [crashDialogOpen, setCrashDialogOpen] = useState(false)
useEffect(() => {
void ipcCall(() => window.electronAPI.log.hadCrash()).then((had) => {
if (had) setCrashDialogOpen(true)
})
}, [])
useEffect(() => { useEffect(() => {
void (async () => { void (async () => {
@@ -57,8 +79,6 @@ function AppInner(): React.JSX.Element {
const openSearch = (): void => useSearchStore.getState().setOpen(true) const openSearch = (): void => useSearchStore.getState().setOpen(true)
const openLandmarks = (): void => useAppStore.getState().setLandmarksModalOpen(true) const openLandmarks = (): void => useAppStore.getState().setLandmarksModalOpen(true)
const openInspiration = (): void => { const openInspiration = (): void => {
if (useAppStore.getState().view !== 'editor') return
if (!useBookStore.getState().currentBookId) return
useAppStore.getState().setInspirationModalOpen(true) useAppStore.getState().setInspirationModalOpen(true)
} }
const onInsertLandmark = (e: Event): void => { const onInsertLandmark = (e: Event): void => {
@@ -148,31 +168,65 @@ function AppInner(): React.JSX.Element {
return return
} }
if (action === 'captureInspiration') { if (action === 'captureInspiration') {
if (view !== 'editor') return
if (!useBookStore.getState().currentBookId) return
useAppStore.getState().setInspirationModalOpen(true) useAppStore.getState().setInspirationModalOpen(true)
return 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')) showToast(t('feature.comingSoon'))
}) })
return unsub return unsub
}, [view, activeTabId, openTabs, openBook, setView, showToast, t]) }, [view, activeTabId, openTabs, openBook, setView, setFocusMode, showToast, t])
const handleOnboardingComplete = (): void => { const handleOnboardingComplete = (): void => {
setShowOnboarding(false) setShowOnboarding(false)
} }
return ( return (
<div id="app"> <div id="app" className={focusMode ? 'focus-mode' : undefined} data-testid="app-root">
<TopBar /> <TopBar />
<UpdateBanner />
<div id="main-area"> <div id="main-area">
{view === 'home' && <HomePage />} {view === 'home' && <HomePage />}
{view === 'editor' && <EditorLayout />} {view === 'editor' && <EditorLayout />}
{view === 'settings' && <SettingsPage />} {view === 'settings' && <SettingsPage />}
</div> </div>
<FocusModeOverlay />
<ReadModeOverlay />
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />} {showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
<SearchModal /> <SearchModal />
<InspirationModal /> <InspirationModal />
<ImportBookModal />
<ExportAllModal />
<TimelineModal />
<CrashReportDialog open={crashDialogOpen} onClose={() => setCrashDialogOpen(false)} />
<NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} />
{toast && <div className="toast">{toast}</div>} {toast && <div className="toast">{toast}</div>}
</div> </div>
) )
+24 -3
View File
@@ -6,12 +6,14 @@ import { useAppStore } from '@renderer/stores/useAppStore'
import { useAiStore } from '@renderer/stores/useAiStore' import { useAiStore } from '@renderer/stores/useAiStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore' import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { applySlashCommand } from '@renderer/lib/ai-slash-commands' import { applySlashCommand } from '@renderer/lib/ai-slash-commands'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { insertToEditor } from '@renderer/lib/editor-commands' import { insertToEditor } from '@renderer/lib/editor-commands'
import { ipcCall } from '@renderer/lib/ipc-client' import { ipcCall } from '@renderer/lib/ipc-client'
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal' import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
import { InteractivePanel } from '@renderer/components/ai/InteractivePanel' import { InteractivePanel } from '@renderer/components/ai/InteractivePanel'
import { AutoPanel } from '@renderer/components/ai/AutoPanel' import { AutoPanel } from '@renderer/components/ai/AutoPanel'
import { WizardPanel } from '@renderer/components/ai/WizardPanel' import { WizardPanel } from '@renderer/components/ai/WizardPanel'
import { AiSessionMenu } from '@renderer/components/ai/AiSessionMenu'
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore' import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
import { useAutoStore } from '@renderer/stores/useAutoStore' import { useAutoStore } from '@renderer/stores/useAutoStore'
import { useWizardStore } from '@renderer/stores/useWizardStore' import { useWizardStore } from '@renderer/stores/useWizardStore'
@@ -28,6 +30,7 @@ const QUICK_CMDS = ['/续写', '/扩写', '/润色', '/总结', '/纠错']
export function AiPanel(): React.JSX.Element { export function AiPanel(): React.JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast) const showToast = useAppStore((s) => s.showToast)
const customSlashCommands = useSettingsStore((s) => s.customSlashCommands)
const bookId = useBookStore((s) => s.currentBookId) const bookId = useBookStore((s) => s.currentBookId)
const sessions = useAiStore((s) => s.sessions) const sessions = useAiStore((s) => s.sessions)
const activeSessionId = useAiStore((s) => s.activeSessionId) const activeSessionId = useAiStore((s) => s.activeSessionId)
@@ -75,7 +78,7 @@ export function AiPanel(): React.JSX.Element {
useEffect(() => { useEffect(() => {
if (bookId) { if (bookId) {
void loadSessions(bookId) void loadSessions(bookId, true)
void checkGate(bookId) void checkGate(bookId)
void checkAutoGate(bookId) void checkAutoGate(bookId)
void checkWizardGate(bookId) void checkWizardGate(bookId)
@@ -89,7 +92,7 @@ export function AiPanel(): React.JSX.Element {
const handleSend = (): void => { const handleSend = (): void => {
const text = input.trim() const text = input.trim()
if (!text || sending || disabled) return if (!text || sending || disabled) return
const { display, prompt } = applySlashCommand(text, t) const { display, prompt } = applySlashCommand(text, t, customSlashCommands)
setInput('') setInput('')
void sendMessage(display, prompt !== display ? prompt : undefined) 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 ( return (
<> <>
<div id="ai-panel" className="ai-panel" data-testid="ai-panel"> <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)} onChange={(e) => void selectSession(e.target.value)}
> >
{sessions.length === 0 && <option value="">{t('ai.noSession')}</option>} {sessions.length === 0 && <option value="">{t('ai.noSession')}</option>}
{sessions.map((s) => ( {activeSessions.length > 0 && (
<optgroup label={t('ai.session.active')}>
{activeSessions.map((s) => (
<option key={s.id} value={s.id}> <option key={s.id} value={s.id}>
{s.title || t('ai.untitledSession')} {s.title || t('ai.untitledSession')}
</option> </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> </select>
<AiSessionMenu bookId={bookId} session={activeSession} />
<button <button
type="button" type="button"
className="btn" 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