Compare commits
16 Commits
d4122c8f95
...
fe421ffc55
| Author | SHA1 | Date | |
|---|---|---|---|
| fe421ffc55 | |||
| cb6b4c3731 | |||
| c5c0b7329b | |||
| c10be87d18 | |||
| fe6e411d2c | |||
| a4412793f4 | |||
| 9f2b1ae3e0 | |||
| fe127ec3ed | |||
| 64da95f7e1 | |||
| cc97c66d6f | |||
| adf877861d | |||
| ea4819847f | |||
| 027d7c7559 | |||
| 4adafa1936 | |||
| 2c9b034a29 | |||
| e64353f608 |
@@ -1,10 +1,55 @@
|
||||
# 笔临 (Bilin)
|
||||
|
||||
长篇创作智能协作平台 — Electron 桌面客户端 v0.9.0(P5.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(含目录)、pdf(A4)
|
||||
- 连续阅读模式:设备宽度、字体主题、虚拟 3 章翻页
|
||||
- 驾驶舱今日大纲任务、专注模式环境音效
|
||||
|
||||
## 功能概览(v1.3.0)
|
||||
|
||||
- `.novel` 单书项目包导出/导入(含数据库、元数据、附件)
|
||||
- `.novel-all` 批量导出/导入全部书籍与全局设置
|
||||
- 主页「导出所有书籍」、设置页备份区、导出模态项目包选项
|
||||
- 大文件导出确认与进度推送
|
||||
|
||||
## 功能概览(v1.2.0)
|
||||
|
||||
- 书籍设置:书名/分类/目标字数/简介/封面、驾驶舱偏好、删书
|
||||
- 分卷右键管理、章节双击改名/删除、章节元数据与标签
|
||||
- 模板变量自动填充(含大纲项与角色列表)
|
||||
- AI 会话重命名/归档/导出/删除
|
||||
- 专注模式快捷键、TTS 朗读、顶栏日更进度条
|
||||
|
||||
## 功能概览(v1.1.0)
|
||||
|
||||
- 章节模板与快速新建章(§10)
|
||||
- 导入 txt / md / docx 并自动分章(§11.1)
|
||||
- 投稿预设与单章导出(复制剪贴板 / 存 txt)(§20.2)
|
||||
- 全局灵感捕获增强:任意界面快捷键、未开书可选书、保存仅 toast(§20.6)
|
||||
|
||||
## 功能概览(v1.0.0)
|
||||
|
||||
- 角色关系图谱:Cytoscape 可视化、设定面板关系 CRUD、三种布局(P7)
|
||||
- 个人写作分析:今日字速、时段热力、POV/卷分布、近 7 日趋势(P9)
|
||||
- 章节视角(POV)选择与驾驶舱内嵌分析区
|
||||
- AI 章节知识自动抽取、合并审核、高置信度批量采纳(P5.2)
|
||||
- 知识库 AI 上下文:半自动推荐 + 勾选注入四种写作模式(P6)
|
||||
- 驾驶舱:全书/本卷字数、存稿缓冲、伏笔摘要、快速入口(P5)
|
||||
|
||||
@@ -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 2–6, 12 |
|
||||
| v1.0.0 DoD | Task 12 |
|
||||
|
||||
---
|
||||
|
||||
## Execution Notes
|
||||
|
||||
- `CharacterGraphService` 与 `SettingRelationshipsEditor` 共用 `graph.upsertRelationship` IPC,避免 renderer 直接 patch `properties` JSON
|
||||
- Cytoscape 仅在 modal open 时 mount,close 必须 `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=9,published_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` class(CSS 隐藏 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 2–3 |
|
||||
| 进度 IPC | Task 3–4 |
|
||||
| UI 入口 | Task 5–7 |
|
||||
| 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 5(pending + 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 3–6 |
|
||||
| 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 Decoration;GlobalSettings 扩展合规/命令字段。
|
||||
|
||||
**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 1–5 全做,对齐 `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 → W3;W1 → W4;W2 → 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 v9(Wave 1–2)
|
||||
|
||||
**`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 v10(Wave 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 v11(Wave 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 v12(Wave 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-01(mock 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`
|
||||
- WebDAV:PUT/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-01(WebDAV mock server 或 local-folder)
|
||||
- [ ] UT-LOG-01
|
||||
- [ ] IT-UPDATE-01(mock 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 plan(brainstorming 下一阶段):
|
||||
|
||||
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 用 Puppeteer;SQLCipher 明确排除;插件不执行第三方代码 |
|
||||
| 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 │
|
||||
└──────────────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────────────────▼──────────────────────────────────────┐
|
||||
│ 主进程(每书 .sqlite,schema v8) │
|
||||
│ · CharacterGraphService(读 settings,写 properties.relationships) │
|
||||
│ · SettingRepository(已有) │
|
||||
│ · ChapterRepository.pov_character_id(已有) │
|
||||
└──────────────────────────────┬──────────────────────────────────────┘
|
||||
│ IPC
|
||||
┌──────────────────────────────▼──────────────────────────────────────┐
|
||||
│ 渲染进程 │
|
||||
│ · CharacterGraphModal(Cytoscape + 侧栏) │
|
||||
│ · SettingRelationshipsEditor(角色设定页关系 CRUD) │
|
||||
│ · CockpitModal 扩展 analytics 区块 │
|
||||
│ · ChapterHeader / ChapterList:POV 下拉 │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 模块边界
|
||||
|
||||
| 模块 | 职责 | 依赖 |
|
||||
|------|------|------|
|
||||
| `WritingSessionService` | 活跃 session 状态机;5 分钟 idle 切分;`recordActivity` | `WritingSessionRepository` |
|
||||
| `WritingAnalyticsService` | `getSummary(bookId)` 聚合分析指标 | sessions, logs, pomodoro, chapters, settings |
|
||||
| `CharacterGraphService` | 构建 nodes/edges;CRUD 关系;边去重 | `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 1–5
|
||||
- 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 | 同一 session,word_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 Coverage(design/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/数据模型无矛盾 | ✅ |
|
||||
| 测试用例覆盖四模块 | ✅ |
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -8,8 +8,13 @@ export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/main/index.ts')
|
||||
}
|
||||
index: resolve(__dirname, 'src/main/index.ts'),
|
||||
'import-bootstrap': resolve(__dirname, 'src/main/import-bootstrap.ts'),
|
||||
'pack-bootstrap': resolve(__dirname, 'src/main/pack-bootstrap.ts'),
|
||||
'export-matrix-bootstrap': resolve(__dirname, 'src/main/export-matrix-bootstrap.ts'),
|
||||
'sync-bootstrap': resolve(__dirname, 'src/main/sync-bootstrap.ts')
|
||||
},
|
||||
external: ['mammoth', 'marked', 'archiver', 'extract-zip', 'docx', 'electron-log', 'electron-updater']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+1067
-201
File diff suppressed because it is too large
Load Diff
+14
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bilin",
|
||||
"version": "0.9.0",
|
||||
"version": "2.0.0",
|
||||
"description": "笔临 - 长篇创作智能协作平台",
|
||||
"main": "./out/main/index.js",
|
||||
"type": "module",
|
||||
@@ -28,10 +28,19 @@
|
||||
"@tiptap/extension-underline": "^3.27.1",
|
||||
"@tiptap/react": "^3.27.1",
|
||||
"@tiptap/starter-kit": "^3.27.1",
|
||||
"archiver": "^8.0.0",
|
||||
"cytoscape": "^3.34.0",
|
||||
"cytoscape-cose-bilkent": "^4.1.0",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"docx": "^9.7.1",
|
||||
"electron": "^36.9.0",
|
||||
"electron-log": "^5.4.4",
|
||||
"electron-updater": "^6.8.9",
|
||||
"extract-zip": "^2.0.1",
|
||||
"i18next": "^24.2.3",
|
||||
"jotai": "^2.12.1",
|
||||
"mammoth": "^1.12.0",
|
||||
"marked": "^15.0.12",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.4.1",
|
||||
@@ -40,7 +49,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.51.0",
|
||||
"@types/archiver": "^8.0.0",
|
||||
"@types/cytoscape": "^3.21.9",
|
||||
"@types/diff-match-patch": "^1.0.36",
|
||||
"@types/extract-zip": "^2.0.0",
|
||||
"@types/marked": "^6.0.0",
|
||||
"@types/node": "^22.13.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
|
||||
@@ -221,11 +221,39 @@
|
||||
"cockpit.resumeEdit": "Resume writing",
|
||||
"cockpit.bridgeCheck": "Chapter bridge check",
|
||||
"cockpit.forgottenForeshadow": "Forgotten foreshadows",
|
||||
"cockpit.todayOutlineTasks": "Today's outline tasks",
|
||||
"cockpit.outlineTargetWords": "Target {{count}} words",
|
||||
"cockpit.todayProgress": "Today {{current}} / {{goal}} words",
|
||||
"cockpit.streak": "{{days}}-day streak",
|
||||
"cockpit.heatmap": "Writing heatmap",
|
||||
"cockpit.achievements": "Writing achievements",
|
||||
"cockpit.pomodoroToday": "{{count}} pomodoros today",
|
||||
"cockpit.openGraph": "Character relationship graph",
|
||||
"graph.title": "Character relationship graph",
|
||||
"graph.layout.cose": "Force-directed",
|
||||
"graph.layout.grid": "Grid",
|
||||
"graph.layout.circle": "Circle",
|
||||
"graph.truncated": "Too many characters; showing first {{count}} only",
|
||||
"graph.sidebar.node": "Character: {{name}}",
|
||||
"graph.sidebar.edge": "Relationship: {{label}}",
|
||||
"graph.sidebar.hint": "Tap a node or edge for details",
|
||||
"graph.editSetting": "Edit setting",
|
||||
"graph.filter.all": "All characters",
|
||||
"graph.filter.connected": "Connected only",
|
||||
"relationship.title": "Character relationships",
|
||||
"relationship.add": "Add relationship",
|
||||
"relationship.target": "Target character",
|
||||
"relationship.label": "Relationship label",
|
||||
"relationship.intimacy": "Intimacy",
|
||||
"analytics.title": "Writing analytics",
|
||||
"analytics.speedToday": "Today {{wpm}} words/min",
|
||||
"analytics.speedUnknown": "No speed data yet",
|
||||
"analytics.hourlyHeatmap": "Writing hours (last 30 days)",
|
||||
"analytics.povDistribution": "POV word distribution",
|
||||
"analytics.volumeDistribution": "Words by volume",
|
||||
"analytics.weekTrend": "Last 7 days",
|
||||
"analytics.povUnassigned": "Unassigned POV",
|
||||
"chapter.pov": "POV character",
|
||||
"pomodoro.start": "Start pomodoro",
|
||||
"pomodoro.pause": "Pause",
|
||||
"pomodoro.resume": "Resume",
|
||||
@@ -246,6 +274,30 @@
|
||||
"settings.pomodoroDuration": "Pomodoro duration (minutes)",
|
||||
"settings.systemNotifications": "Enable system notifications",
|
||||
"settings.goalNotifications": "Enable goal notifications",
|
||||
"settings.focusAmbientSound": "Focus mode ambient sound",
|
||||
"settings.complianceCheck": "Enable sensitive word compliance check",
|
||||
"settings.complianceWords": "Custom sensitive words",
|
||||
"settings.complianceWordsHint": "One word per line; merged with built-in list",
|
||||
"timeline.open": "Story timeline",
|
||||
"timeline.title": "Story timeline",
|
||||
"timeline.empty": "No timeline events yet",
|
||||
"timeline.noTime": "No time set",
|
||||
"timeline.manual": "Manual event",
|
||||
"timeline.addEvent": "Add event",
|
||||
"timeline.jumpChapter": "Go to chapter",
|
||||
"timeline.eventTitlePrompt": "Event title",
|
||||
"timeline.storyTimePrompt": "Story time (e.g. 732 or +10y)",
|
||||
"timeline.characterHint": "Set character birth year in the arc panel for conflict detection",
|
||||
"arc.title": "Character arc",
|
||||
"arc.birthDate": "Birth year",
|
||||
"arc.empty": "No arc nodes yet; knowledge entries will appear as you write",
|
||||
"arc.jumpChapter": "Go to chapter",
|
||||
"reference.openInEditor": "Open in editor",
|
||||
"reference.detailTitle": "Reference detail",
|
||||
"ai.settings.customSlashCommands": "Custom slash commands",
|
||||
"ai.settings.customSlashName": "Command (e.g. /outline)",
|
||||
"ai.settings.customSlashTemplate": "Expansion template",
|
||||
"ai.settings.customSlashAdd": "Add command",
|
||||
"bridge.title": "Chapter bridge",
|
||||
"bridge.loading": "Analyzing…",
|
||||
"bridge.previousTail": "Previous chapter ending",
|
||||
@@ -374,5 +426,213 @@
|
||||
"landmark.created": "Landmark inserted",
|
||||
"landmark.chapterOnly": "Insert landmarks in a chapter",
|
||||
"wordfreq.hint": "Top 100 words in current scope",
|
||||
"wordfreq.aiNaming": "AI naming suggestions"
|
||||
"wordfreq.aiNaming": "AI naming suggestions",
|
||||
"settings.templates": "Chapter Templates",
|
||||
"settings.submission": "Submission Presets",
|
||||
"dialog.edit": "Edit",
|
||||
"dialog.delete": "Delete",
|
||||
"template.title": "Chapter Templates",
|
||||
"template.quickNew": "New from Template",
|
||||
"template.builtin": "Built-in",
|
||||
"template.custom": "Custom",
|
||||
"template.selectTemplate": "Select template",
|
||||
"template.chapterTitle": "Chapter title",
|
||||
"template.chapterTitlePlaceholder": "Enter chapter title",
|
||||
"template.createChapter": "Create chapter",
|
||||
"template.add": "Add template",
|
||||
"template.newTemplate": "New template",
|
||||
"template.name": "Template name",
|
||||
"template.body": "Template body",
|
||||
"import.title": "Import Book",
|
||||
"import.pickFile": "Browse file",
|
||||
"import.noFile": "No file selected",
|
||||
"import.splitMode": "Split mode",
|
||||
"import.splitAuto": "Auto-detect chapters",
|
||||
"import.splitParagraph": "Split by blank lines",
|
||||
"import.splitRegex": "Custom regex",
|
||||
"import.customRegex": "Regex pattern",
|
||||
"import.preview": "Preview chapters",
|
||||
"import.chapterCount": "{{count}} chapter(s)",
|
||||
"import.largeFileConfirm": "Large file ({{size}}). Import may take 30s–2min. 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"
|
||||
}
|
||||
|
||||
@@ -221,11 +221,39 @@
|
||||
"cockpit.resumeEdit": "继续写作",
|
||||
"cockpit.bridgeCheck": "章间衔接检查",
|
||||
"cockpit.forgottenForeshadow": "查看遗忘伏笔",
|
||||
"cockpit.todayOutlineTasks": "今日大纲任务",
|
||||
"cockpit.outlineTargetWords": "目标 {{count}} 字",
|
||||
"cockpit.todayProgress": "今日 {{current}} / {{goal}} 字",
|
||||
"cockpit.streak": "连更 {{days}} 天",
|
||||
"cockpit.heatmap": "写作热力图",
|
||||
"cockpit.achievements": "写作成就",
|
||||
"cockpit.pomodoroToday": "今日番茄 {{count}} 次",
|
||||
"cockpit.openGraph": "角色关系图谱",
|
||||
"graph.title": "角色关系图谱",
|
||||
"graph.layout.cose": "力导向",
|
||||
"graph.layout.grid": "网格",
|
||||
"graph.layout.circle": "圆形",
|
||||
"graph.truncated": "角色过多,仅显示前 {{count}} 个",
|
||||
"graph.sidebar.node": "角色:{{name}}",
|
||||
"graph.sidebar.edge": "关系:{{label}}",
|
||||
"graph.sidebar.hint": "点击节点或边查看详情",
|
||||
"graph.editSetting": "编辑设定",
|
||||
"graph.filter.all": "全部角色",
|
||||
"graph.filter.connected": "仅有关系",
|
||||
"relationship.title": "角色关系",
|
||||
"relationship.add": "添加关系",
|
||||
"relationship.target": "目标角色",
|
||||
"relationship.label": "关系描述",
|
||||
"relationship.intimacy": "亲密度",
|
||||
"analytics.title": "写作分析",
|
||||
"analytics.speedToday": "今日 {{wpm}} 字/分钟",
|
||||
"analytics.speedUnknown": "暂无速度数据",
|
||||
"analytics.hourlyHeatmap": "写作时段(近30天)",
|
||||
"analytics.povDistribution": "视角字数分布",
|
||||
"analytics.volumeDistribution": "按卷字数",
|
||||
"analytics.weekTrend": "近 7 日字数",
|
||||
"analytics.povUnassigned": "未指定视角",
|
||||
"chapter.pov": "视角角色",
|
||||
"pomodoro.start": "开始番茄钟",
|
||||
"pomodoro.pause": "暂停",
|
||||
"pomodoro.resume": "继续",
|
||||
@@ -246,6 +274,30 @@
|
||||
"settings.pomodoroDuration": "番茄钟时长(分钟)",
|
||||
"settings.systemNotifications": "启用系统通知",
|
||||
"settings.goalNotifications": "启用目标达成提醒",
|
||||
"settings.focusAmbientSound": "专注模式环境音效",
|
||||
"settings.complianceCheck": "启用敏感词合规检查",
|
||||
"settings.complianceWords": "自定义敏感词库",
|
||||
"settings.complianceWordsHint": "每行一个词,将与内置词库合并扫描",
|
||||
"timeline.open": "故事时间线",
|
||||
"timeline.title": "故事时间线",
|
||||
"timeline.empty": "暂无时间线事件",
|
||||
"timeline.noTime": "未标记时间",
|
||||
"timeline.manual": "手动事件",
|
||||
"timeline.addEvent": "添加事件",
|
||||
"timeline.jumpChapter": "跳转章节",
|
||||
"timeline.eventTitlePrompt": "事件标题",
|
||||
"timeline.storyTimePrompt": "故事时间(如 732 或 +10y)",
|
||||
"timeline.characterHint": "角色出生日期可在角色设定弧线面板中配置,用于冲突检测",
|
||||
"arc.title": "角色弧线",
|
||||
"arc.birthDate": "出生年份",
|
||||
"arc.empty": "暂无弧线节点,写作后知识库会自动聚合",
|
||||
"arc.jumpChapter": "跳转章节",
|
||||
"reference.openInEditor": "在编辑器中打开",
|
||||
"reference.detailTitle": "引用详情",
|
||||
"ai.settings.customSlashCommands": "自定义快捷命令",
|
||||
"ai.settings.customSlashName": "命令名(如 /大纲)",
|
||||
"ai.settings.customSlashTemplate": "展开模板",
|
||||
"ai.settings.customSlashAdd": "添加命令",
|
||||
"bridge.title": "章间衔接",
|
||||
"bridge.loading": "分析中…",
|
||||
"bridge.previousTail": "上一章末尾",
|
||||
@@ -374,5 +426,213 @@
|
||||
"landmark.created": "地标已插入",
|
||||
"landmark.chapterOnly": "请在章节中插入地标",
|
||||
"wordfreq.hint": "当前范围词频 Top 100",
|
||||
"wordfreq.aiNaming": "AI 命名建议"
|
||||
"wordfreq.aiNaming": "AI 命名建议",
|
||||
"settings.templates": "章节模板",
|
||||
"settings.submission": "投稿预设",
|
||||
"dialog.edit": "编辑",
|
||||
"dialog.delete": "删除",
|
||||
"template.title": "章节模板",
|
||||
"template.quickNew": "模板新建章",
|
||||
"template.builtin": "内置",
|
||||
"template.custom": "自定义",
|
||||
"template.selectTemplate": "选择模板",
|
||||
"template.chapterTitle": "章节标题",
|
||||
"template.chapterTitlePlaceholder": "输入本章标题",
|
||||
"template.createChapter": "创建章节",
|
||||
"template.add": "新建模板",
|
||||
"template.newTemplate": "新模板",
|
||||
"template.name": "模板名称",
|
||||
"template.body": "模板正文",
|
||||
"import.title": "导入书籍",
|
||||
"import.pickFile": "浏览文件",
|
||||
"import.noFile": "未选择文件",
|
||||
"import.splitMode": "分章规则",
|
||||
"import.splitAuto": "自动识别章节",
|
||||
"import.splitParagraph": "双换行分段",
|
||||
"import.splitRegex": "自定义正则",
|
||||
"import.customRegex": "正则表达式",
|
||||
"import.preview": "预览分章",
|
||||
"import.chapterCount": "共 {{count}} 章",
|
||||
"import.largeFileConfirm": "文件较大({{size}}),导入可能需要 30 秒至 2 分钟,是否继续?",
|
||||
"import.progress": "正在导入 {{current}}/{{total}}",
|
||||
"import.start": "开始导入",
|
||||
"import.importing": "导入中…",
|
||||
"import.success": "导入成功",
|
||||
"import.failed": "导入失败",
|
||||
"export.title": "导出章节",
|
||||
"export.submissionPresets": "投稿预设",
|
||||
"export.copyClipboard": "复制到剪贴板",
|
||||
"export.saveTxt": "另存为文本",
|
||||
"export.copied": "已复制到剪贴板",
|
||||
"export.saved": "已保存至 {{path}}",
|
||||
"export.preview": "预览",
|
||||
"export.loading": "加载中…",
|
||||
"export.noPreview": "(无预览)",
|
||||
"export.kind": "导出类型",
|
||||
"export.kindSubmission": "投稿格式(当前章节)",
|
||||
"export.kindNovel": "笔临项目包 (.novel)",
|
||||
"export.novelDesc": "导出整本书的数据库、元数据与附件,可在其他设备或备份后通过「导入书籍」恢复。",
|
||||
"export.exportNovel": "导出项目包",
|
||||
"export.kindMatrix": "常规导出(txt/md/docx/pdf)",
|
||||
"export.matrixFormat": "文件格式",
|
||||
"export.matrixScope": "导出范围",
|
||||
"export.matrixOptions": "导出选项",
|
||||
"export.scopeChapter": "当前章节",
|
||||
"export.scopeVolume": "当前分卷",
|
||||
"export.scopeBook": "全书",
|
||||
"export.optionAnnotations": "包含批注/地标",
|
||||
"export.optionAiMark": "标注 AI 生成内容",
|
||||
"export.matrixStart": "导出文件",
|
||||
"export.matrixSaved": "已保存至 {{path}}",
|
||||
"export.matrixFailed": "导出失败",
|
||||
"export.exporting": "导出中…",
|
||||
"import.novelHint": "将导入 .novel 项目包中的整本书(含章节、设定与知识库)。",
|
||||
"import.novelAllHint": "将批量导入 .novel-all 包中的所有书籍,请选择冲突处理策略。",
|
||||
"pack.exportAllTitle": "导出所有书籍",
|
||||
"pack.exportAllDesc": "将所有书籍与全局设置打包为 .novel-all 文件。",
|
||||
"pack.estimating": "正在估算大小…",
|
||||
"pack.estimatedSize": "预计大小:{{size}}",
|
||||
"pack.noDest": "未选择保存路径",
|
||||
"pack.pickDest": "选择保存位置",
|
||||
"pack.largeExportConfirm": "导出体积较大({{size}}),确认继续?",
|
||||
"pack.exportAllStart": "开始导出",
|
||||
"pack.exportAllSuccess": "全部书籍已导出",
|
||||
"pack.exportAllFailed": "导出失败",
|
||||
"pack.exportBookSuccess": "「{{name}}」已导出为项目包",
|
||||
"pack.exportBookFailed": "项目包导出失败",
|
||||
"pack.exporting": "导出中…",
|
||||
"pack.cancel": "取消",
|
||||
"pack.progress": "进度 {{current}}/{{total}}",
|
||||
"pack.importStrategy": "导入策略",
|
||||
"pack.strategyNew": "全部作为新书导入",
|
||||
"pack.strategySkip": "跳过已存在的书",
|
||||
"pack.strategyOverwrite": "覆盖同名书籍",
|
||||
"pack.importAllSuccess": "导入完成:新增 {{imported}},跳过 {{skipped}},覆盖 {{overwritten}}",
|
||||
"backup.desc": "将书籍导出为笔临项目包,便于备份、迁移或在其他设备恢复。",
|
||||
"backup.exportCurrentBook": "导出当前书籍",
|
||||
"backup.exportBookBtn": "导出为 .novel",
|
||||
"backup.exportAllBooks": "导出全部书籍",
|
||||
"backup.exportAllBtn": "打开批量导出",
|
||||
"backup.noBook": "请先在编辑器中打开一本书",
|
||||
"backup.openBookHint": "在编辑器中打开书籍后可导出单书项目包",
|
||||
"backup.exportBookSuccess": "「{{name}}」已导出",
|
||||
"backup.exportBookFailed": "导出失败",
|
||||
"backup.formatHint": "超过 500 MB 的批量导出需要额外确认。",
|
||||
"submission.title": "投稿预设",
|
||||
"submission.titleFormat": "标题格式",
|
||||
"submission.indent": "段首空两格",
|
||||
"submission.indentOn": "段首缩进:开",
|
||||
"submission.indentOff": "段首缩进:关",
|
||||
"submission.includeAuthorsNote": "包含作者的话",
|
||||
"submission.paragraphGap": "段落间距",
|
||||
"submission.gapSingle": "单倍行距",
|
||||
"submission.gapDouble": "双倍行距",
|
||||
"submission.add": "新建预设",
|
||||
"submission.newPreset": "新投稿预设",
|
||||
"submission.name": "预设名称",
|
||||
"submission.setDefault": "设为默认预设",
|
||||
"inspiration.selectBook": "关联书籍",
|
||||
"inspiration.saved": "灵感已保存",
|
||||
"inspiration.noBooks": "请先创建一本书再保存灵感",
|
||||
"bookSettings.title": "书籍设置",
|
||||
"bookSettings.synopsis": "简介",
|
||||
"bookSettings.cover": "封面",
|
||||
"bookSettings.pickCover": "选择封面图片",
|
||||
"bookSettings.noCover": "未设置",
|
||||
"bookSettings.alwaysShowCockpit": "每次打开显示驾驶舱",
|
||||
"bookSettings.dangerZone": "危险操作",
|
||||
"bookSettings.deleteBook": "删除此书",
|
||||
"bookSettings.deleteConfirm": "确定删除「{{name}}」?此操作不可恢复。",
|
||||
"volume.rename": "重命名分卷",
|
||||
"volume.renamePrompt": "分卷名称",
|
||||
"volume.editDescription": "编辑简述",
|
||||
"volume.descriptionPrompt": "分卷简述",
|
||||
"volume.delete": "删除分卷",
|
||||
"volume.deleteConfirm": "确定删除分卷「{{name}}」?",
|
||||
"volume.deleteHasChapters": "分卷内仍有章节,无法删除",
|
||||
"chapter.delete": "删除章节",
|
||||
"chapter.deleteConfirm": "确定删除章节「{{title}}」?",
|
||||
"chapterMeta.title": "章节元数据",
|
||||
"chapterMeta.open": "元数据",
|
||||
"chapterMeta.summary": "章节摘要",
|
||||
"chapterMeta.storyTime": "故事时间",
|
||||
"chapterMeta.wordThreshold": "字数阈值",
|
||||
"chapterMeta.tags": "标签",
|
||||
"chapterMeta.tagPlaceholder": "输入标签",
|
||||
"chapterMeta.addTag": "添加",
|
||||
"chapterMeta.publishedAt": "发布于 {{date}}",
|
||||
"ai.session.rename": "重命名会话",
|
||||
"ai.session.renamePrompt": "会话标题",
|
||||
"ai.session.archive": "归档",
|
||||
"ai.session.unarchive": "取消归档",
|
||||
"ai.session.export": "导出",
|
||||
"ai.session.exportMd": "Markdown (.md)",
|
||||
"ai.session.exportTxt": "纯文本 (.txt)",
|
||||
"ai.session.delete": "删除会话",
|
||||
"ai.session.deleteConfirm": "确定删除会话「{{title}}」?",
|
||||
"ai.session.active": "活跃",
|
||||
"ai.session.archived": "已归档",
|
||||
"focus.toggle": "专注模式",
|
||||
"focus.exit": "退出专注",
|
||||
"tts.started": "开始朗读",
|
||||
"tts.stopped": "已停止朗读",
|
||||
"tts.noText": "没有可朗读的内容",
|
||||
"home.exportAllWave2": "批量导出将在后续版本提供",
|
||||
"readMode.title": "阅读模式",
|
||||
"readMode.open": "阅读模式",
|
||||
"readMode.exit": "退出阅读",
|
||||
"readMode.deviceWidth": "设备宽度",
|
||||
"readMode.devicePhone": "手机 (360px)",
|
||||
"readMode.deviceTablet": "平板 (768px)",
|
||||
"readMode.deviceDesktop": "桌面 (100%)",
|
||||
"readMode.fontTheme": "字体主题",
|
||||
"readMode.fontSerif": "衬线",
|
||||
"readMode.fontSans": "无衬线",
|
||||
"readMode.fontEyeCare": "护眼",
|
||||
"readMode.tts": "朗读",
|
||||
"settings.extensions": "扩展",
|
||||
"sync.title": "云同步",
|
||||
"sync.desc": "将加密后的全部书籍备份同步到 WebDAV 或本地云盘目录",
|
||||
"sync.mode": "同步方式",
|
||||
"sync.modeLocal": "本地目录",
|
||||
"sync.modeWebdav": "WebDAV",
|
||||
"sync.localPathPlaceholder": "选择或输入同步目录路径",
|
||||
"sync.pickFolder": "选择目录",
|
||||
"sync.webdavUrl": "WebDAV 地址",
|
||||
"sync.webdavUser": "WebDAV 用户名",
|
||||
"sync.schedule": "同步计划",
|
||||
"sync.scheduleManual": "手动",
|
||||
"sync.scheduleHourly": "每小时",
|
||||
"sync.scheduleDaily": "每天",
|
||||
"sync.scheduleWeekly": "每周",
|
||||
"sync.passwordPlaceholder": "同步密码(至少 8 位)",
|
||||
"sync.passwordHint": "请设置至少 8 位的同步密码",
|
||||
"sync.saveConfig": "保存同步配置",
|
||||
"sync.runNow": "立即同步",
|
||||
"sync.running": "同步中…",
|
||||
"sync.done": "同步完成",
|
||||
"sync.configSaved": "同步配置已保存",
|
||||
"sync.lastSync": "上次同步:{{time}}({{status}})",
|
||||
"sync.conflictTitle": "同步冲突",
|
||||
"sync.conflictDesc": "本地与远程均有更新,请选择保留哪一侧的数据。",
|
||||
"sync.useRemote": "使用远程",
|
||||
"sync.useLocal": "使用本地",
|
||||
"extensions.title": "扩展",
|
||||
"extensions.desc": "扩展 API 骨架(不执行第三方代码)",
|
||||
"extensions.devMode": "开发者模式(仅校验 manifest)",
|
||||
"extensions.stubHint": "完整插件市场将在后续版本提供。",
|
||||
"update.available": "新版本 {{version}} 可用",
|
||||
"update.download": "下载更新",
|
||||
"update.downloading": "下载中 {{percent}}%",
|
||||
"update.install": "重启并安装",
|
||||
"crash.title": "检测到异常退出",
|
||||
"crash.desc": "上次使用时应用可能异常退出。可发送本地日志报告(当前为 stub)。",
|
||||
"crash.send": "发送报告",
|
||||
"crash.dismiss": "忽略",
|
||||
"ai.settings.builtinSkills": "内置 Skill",
|
||||
"ai.settings.skillContinue": "续写助手",
|
||||
"ai.settings.skillPolish": "润色助手",
|
||||
"ai.settings.skillProofread": "纠错助手",
|
||||
"ai.settings.mcpConnector": "MCP 连接器 URL",
|
||||
"ai.settings.mcpTest": "测试连接"
|
||||
}
|
||||
|
||||
+23
-1
@@ -7,8 +7,12 @@ import schemaV5 from './schema-v5.sql?raw'
|
||||
import schemaV6 from './schema-v6.sql?raw'
|
||||
import schemaV7 from './schema-v7.sql?raw'
|
||||
import schemaV8 from './schema-v8.sql?raw'
|
||||
import schemaV9 from './schema-v9.sql?raw'
|
||||
|
||||
const CURRENT_VERSION = 8
|
||||
import schemaV10 from './schema-v10.sql?raw'
|
||||
import schemaV11 from './schema-v11.sql?raw'
|
||||
|
||||
const CURRENT_VERSION = 11
|
||||
|
||||
function tryAlter(db: SqliteDb, sql: string): void {
|
||||
try {
|
||||
@@ -88,5 +92,23 @@ export function migrate(db: SqliteDb): void {
|
||||
if (current < 8) {
|
||||
db.exec(schemaV8)
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(8, 'P5.3 P6.1 goals injection')
|
||||
current = 8
|
||||
}
|
||||
|
||||
if (current < 9) {
|
||||
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN published_at TEXT DEFAULT NULL')
|
||||
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN word_count_threshold INTEGER DEFAULT NULL')
|
||||
db.exec(schemaV9)
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(9, 'W1 foundation')
|
||||
}
|
||||
|
||||
if (current < 10) {
|
||||
db.exec(schemaV10)
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(10, 'W4 timeline')
|
||||
}
|
||||
|
||||
if (current < 11) {
|
||||
db.exec(schemaV11)
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(11, 'W5 undo stack')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ChapterTag } from '../../../shared/types'
|
||||
import type { SqliteDb } from '../types'
|
||||
|
||||
function mapRow(row: Record<string, unknown>): ChapterTag {
|
||||
return {
|
||||
chapterId: row.chapter_id as string,
|
||||
tag: row.tag as string,
|
||||
color: (row.color as string) ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
export class ChapterTagRepository {
|
||||
constructor(private db: SqliteDb) {}
|
||||
|
||||
list(chapterId: string): ChapterTag[] {
|
||||
return (
|
||||
this.db
|
||||
.prepare('SELECT * FROM chapter_tags WHERE chapter_id = ? ORDER BY tag')
|
||||
.all(chapterId) as Record<string, unknown>[]
|
||||
).map(mapRow)
|
||||
}
|
||||
|
||||
listAll(): ChapterTag[] {
|
||||
return (
|
||||
this.db.prepare('SELECT * FROM chapter_tags ORDER BY chapter_id, tag').all() as Record<
|
||||
string,
|
||||
unknown
|
||||
>[]
|
||||
).map(mapRow)
|
||||
}
|
||||
|
||||
set(chapterId: string, tag: string, color = ''): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO chapter_tags (chapter_id, tag, color) VALUES (?, ?, ?)
|
||||
ON CONFLICT(chapter_id, tag) DO UPDATE SET color = excluded.color`
|
||||
)
|
||||
.run(chapterId, tag, color)
|
||||
}
|
||||
|
||||
remove(chapterId: string, tag: string): void {
|
||||
this.db.prepare('DELETE FROM chapter_tags WHERE chapter_id = ? AND tag = ?').run(chapterId, tag)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,12 @@ function mapRow(row: Record<string, unknown>): Chapter {
|
||||
sortOrder: row.sort_order as number,
|
||||
cursorOffset: (row.cursor_offset as number) ?? 0,
|
||||
origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin,
|
||||
publishStatus: ((row.publish_status as PublishStatus) ?? 'draft') as PublishStatus
|
||||
publishStatus: ((row.publish_status as PublishStatus) ?? 'draft') as PublishStatus,
|
||||
povCharacterId: (row.pov_character_id as string) || null,
|
||||
summary: (row.summary as string) ?? '',
|
||||
storyTime: (row.story_time as string) ?? null,
|
||||
publishedAt: (row.published_at as string) ?? null,
|
||||
wordCountThreshold: (row.word_count_threshold as number) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +71,11 @@ export class ChapterRepository {
|
||||
status: ChapterStatus
|
||||
cursorOffset: number
|
||||
publishStatus: PublishStatus
|
||||
povCharacterId: string | null
|
||||
summary: string
|
||||
storyTime: string | null
|
||||
publishedAt: string | null
|
||||
wordCountThreshold: number | null
|
||||
}>
|
||||
): Chapter {
|
||||
const existing = this.get(id)
|
||||
@@ -73,12 +83,16 @@ export class ChapterRepository {
|
||||
|
||||
const content = patch.content ?? existing.content
|
||||
const wordCount = countWords(content)
|
||||
const povCharacterId =
|
||||
patch.povCharacterId !== undefined ? patch.povCharacterId : existing.povCharacterId ?? null
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE chapters SET
|
||||
title = ?, content = ?, status = ?, cursor_offset = ?,
|
||||
publish_status = ?, word_count = ?, updated_at = datetime('now')
|
||||
publish_status = ?, pov_character_id = ?, word_count = ?,
|
||||
summary = ?, story_time = ?, published_at = ?, word_count_threshold = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
@@ -87,14 +101,25 @@ export class ChapterRepository {
|
||||
patch.status ?? existing.status,
|
||||
patch.cursorOffset ?? existing.cursorOffset,
|
||||
patch.publishStatus ?? existing.publishStatus ?? 'draft',
|
||||
povCharacterId,
|
||||
wordCount,
|
||||
patch.summary ?? existing.summary ?? '',
|
||||
patch.storyTime !== undefined ? patch.storyTime : (existing.storyTime ?? null),
|
||||
patch.publishedAt !== undefined ? patch.publishedAt : (existing.publishedAt ?? null),
|
||||
patch.wordCountThreshold !== undefined
|
||||
? patch.wordCountThreshold
|
||||
: (existing.wordCountThreshold ?? null),
|
||||
id
|
||||
)
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
setPublishStatus(id: string, publishStatus: PublishStatus): Chapter {
|
||||
return this.update(id, { publishStatus })
|
||||
const patch: Parameters<ChapterRepository['update']>[1] = { publishStatus }
|
||||
if (publishStatus === 'published') {
|
||||
patch.publishedAt = new Date().toISOString()
|
||||
}
|
||||
return this.update(id, patch)
|
||||
}
|
||||
|
||||
countByPublishStatus(status: PublishStatus): number {
|
||||
|
||||
@@ -22,4 +22,19 @@ export class PomodoroDailyRepository {
|
||||
.get(date, bookId) as { completed_count: number } | undefined
|
||||
return row?.completed_count ?? 0
|
||||
}
|
||||
|
||||
getTodayStats(
|
||||
bookId: string,
|
||||
date = localDateString()
|
||||
): { completedCount: number; totalWords: number } {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
'SELECT completed_count, total_words FROM pomodoro_daily WHERE date = ? AND book_id = ?'
|
||||
)
|
||||
.get(date, bookId) as { completed_count: number; total_words: number } | undefined
|
||||
return {
|
||||
completedCount: row?.completed_count ?? 0,
|
||||
totalWords: row?.total_words ?? 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,18 @@ export class WritingLogRepository {
|
||||
completed_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_words INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (date, book_id)
|
||||
)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS writing_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
book_id TEXT NOT NULL,
|
||||
chapter_id TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT NOT NULL,
|
||||
word_delta INTEGER NOT NULL DEFAULT 0,
|
||||
date TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_book_date ON writing_sessions (book_id, date);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_book_started ON writing_sessions (book_id, started_at)
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
export interface WritingSessionRow {
|
||||
id: string
|
||||
bookId: string
|
||||
chapterId?: string
|
||||
startedAt: string
|
||||
endedAt: string
|
||||
wordDelta: number
|
||||
date: string
|
||||
}
|
||||
|
||||
function mapRow(row: Record<string, unknown>): WritingSessionRow {
|
||||
return {
|
||||
id: row.id as string,
|
||||
bookId: row.book_id as string,
|
||||
chapterId: (row.chapter_id as string) || undefined,
|
||||
startedAt: row.started_at as string,
|
||||
endedAt: row.ended_at as string,
|
||||
wordDelta: row.word_delta as number,
|
||||
date: row.date as string
|
||||
}
|
||||
}
|
||||
|
||||
export class WritingSessionRepository {
|
||||
constructor(private db: DatabaseSync) {}
|
||||
|
||||
insert(row: {
|
||||
id: string
|
||||
bookId: string
|
||||
chapterId?: string
|
||||
startedAt: string
|
||||
endedAt: string
|
||||
wordDelta: number
|
||||
date: string
|
||||
}): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO writing_sessions (id, book_id, chapter_id, started_at, ended_at, word_delta, date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
row.id,
|
||||
row.bookId,
|
||||
row.chapterId ?? null,
|
||||
row.startedAt,
|
||||
row.endedAt,
|
||||
row.wordDelta,
|
||||
row.date
|
||||
)
|
||||
}
|
||||
|
||||
updateActivity(id: string, endedAt: string, wordDelta: number): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE writing_sessions SET ended_at = ?, word_delta = word_delta + ? WHERE id = ?`
|
||||
)
|
||||
.run(endedAt, wordDelta, id)
|
||||
}
|
||||
|
||||
getById(id: string): WritingSessionRow | null {
|
||||
const row = this.db.prepare('SELECT * FROM writing_sessions WHERE id = ?').get(id) as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
return row ? mapRow(row) : null
|
||||
}
|
||||
|
||||
listForBookDate(bookId: string, date: string): WritingSessionRow[] {
|
||||
const rows = this.db
|
||||
.prepare('SELECT * FROM writing_sessions WHERE book_id = ? AND date = ? ORDER BY started_at')
|
||||
.all(bookId, date) as Record<string, unknown>[]
|
||||
return rows.map(mapRow)
|
||||
}
|
||||
|
||||
listSince(bookId: string, sinceIso: string): WritingSessionRow[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM writing_sessions WHERE book_id = ? AND started_at >= ? ORDER BY started_at`
|
||||
)
|
||||
.all(bookId, sinceIso) as Record<string, unknown>[]
|
||||
return rows.map(mapRow)
|
||||
}
|
||||
}
|
||||
@@ -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'))
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -50,8 +50,16 @@ function createWindow(): void { mainWindow = new BrowserWindow({
|
||||
getNetworkMonitor()?.start(() => mainWindow)
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerIpc()
|
||||
app.whenReady().then(async () => {
|
||||
const { books, settings } = registerIpc()
|
||||
const { bootstrapImportHandlers } = await import('./import-bootstrap.js')
|
||||
bootstrapImportHandlers(books)
|
||||
const { bootstrapPackHandlers } = await import('./pack-bootstrap.js')
|
||||
bootstrapPackHandlers(books, settings)
|
||||
const { bootstrapExportMatrixHandlers } = await import('./export-matrix-bootstrap.js')
|
||||
bootstrapExportMatrixHandlers(books, settings)
|
||||
const { bootstrapSyncHandlers } = await import('./sync-bootstrap.js')
|
||||
bootstrapSyncHandlers(books, settings)
|
||||
|
||||
ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => {
|
||||
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { writeFileSync } from 'fs'
|
||||
import { BrowserWindow, dialog, ipcMain } from 'electron'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import type { AiConfig, AiContextBinding, NamingConflict } from '../../../shared/types'
|
||||
import { BookRegistryService } from '../../services/book-registry'
|
||||
@@ -19,6 +20,15 @@ import { wrap } from '../result'
|
||||
|
||||
const activeChats = new Map<string, AbortController>()
|
||||
|
||||
function formatSessionMarkdown(title: string, messages: { role: string; content: string; createdAt: string }[]): string {
|
||||
const lines = [`# ${title || 'AI 会话'}`, '']
|
||||
for (const msg of messages) {
|
||||
const label = msg.role === 'user' ? '用户' : msg.role === 'assistant' ? '助手' : msg.role
|
||||
lines.push(`## ${label}`, '', msg.content, '', `_${msg.createdAt}_`, '')
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function registerAiHandlers(
|
||||
registry: BookRegistryService,
|
||||
settings: GlobalSettingsService,
|
||||
@@ -67,6 +77,39 @@ export function registerAiHandlers(
|
||||
wrap(() => registry.getAiSessionRepo(bookId).listMessages(sessionId))
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AI_SESSION_EXPORT,
|
||||
async (
|
||||
_e,
|
||||
{
|
||||
bookId,
|
||||
sessionId,
|
||||
format = 'markdown'
|
||||
}: { bookId: string; sessionId: string; format?: 'markdown' | 'txt' }
|
||||
) =>
|
||||
wrap(async () => {
|
||||
const repo = registry.getAiSessionRepo(bookId)
|
||||
const session = repo.get(sessionId)
|
||||
if (!session) throw new Error('AI session not found')
|
||||
const messages = repo.listMessages(sessionId)
|
||||
const text =
|
||||
format === 'markdown'
|
||||
? formatSessionMarkdown(session.title, messages)
|
||||
: messages
|
||||
.map((m) => `[${m.role}] ${m.createdAt}\n${m.content}`)
|
||||
.join('\n\n---\n\n')
|
||||
|
||||
const ext = format === 'markdown' ? 'md' : 'txt'
|
||||
const { canceled, filePath } = await dialog.showSaveDialog({
|
||||
defaultPath: `${session.title || 'ai-session'}.${ext}`,
|
||||
filters: [{ name: format === 'markdown' ? 'Markdown' : 'Text', extensions: [ext] }]
|
||||
})
|
||||
if (canceled || !filePath) return null
|
||||
writeFileSync(filePath, text, 'utf8')
|
||||
return filePath
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AI_BUILD_CONTEXT,
|
||||
(_e, { bookId, binding }: { bookId: string; binding: AiContextBinding }) =>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import { PomodoroDailyRepository } from '../../db/repositories/pomodoro-daily.repo'
|
||||
import { WritingSessionRepository } from '../../db/repositories/writing-session.repo'
|
||||
import { WritingAnalyticsService } from '../../services/writing-analytics.service'
|
||||
import type { BookRegistryService } from '../../services/book-registry'
|
||||
import type { WritingLogService } from '../../services/writing-log.service'
|
||||
import { wrap } from '../result'
|
||||
|
||||
export function registerAnalyticsHandlers(
|
||||
registry: BookRegistryService,
|
||||
writingLogs: WritingLogService,
|
||||
sessionRepo: WritingSessionRepository,
|
||||
pomodoroDaily: PomodoroDailyRepository
|
||||
): void {
|
||||
const svc = new WritingAnalyticsService(writingLogs, sessionRepo, pomodoroDaily, registry)
|
||||
|
||||
ipcMain.handle(IPC.ANALYTICS_SUMMARY, (_e, { bookId }: { bookId: string }) =>
|
||||
wrap(() => svc.getSummary(bookId))
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { scheduleChapterExtraction } from '../../services/knowledge-extraction-r
|
||||
import { trackerFor } from '../../services/chapter-writing-tracker.service'
|
||||
import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner'
|
||||
import type { WritingLogService } from '../../services/writing-log.service'
|
||||
import type { WritingSessionService } from '../../services/writing-session.service'
|
||||
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
|
||||
import { wrap } from '../result'
|
||||
|
||||
@@ -38,7 +39,8 @@ export function registerAutoHandlers(
|
||||
registry: BookRegistryService,
|
||||
settings: GlobalSettingsService,
|
||||
aiClient: AiClientService,
|
||||
writingLogs: WritingLogService
|
||||
writingLogs: WritingLogService,
|
||||
writingSessions: WritingSessionService
|
||||
): void {
|
||||
|
||||
ipcMain.handle(IPC.AUTO_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
|
||||
@@ -217,7 +219,10 @@ export function registerAutoHandlers(
|
||||
wrap(() => {
|
||||
const result = serviceFor(registry, bookId, aiClient).finishChapter(flowId, volumeId, title)
|
||||
const ch = registry.getChapterRepo(bookId).get(result.chapterId)!
|
||||
trackerFor(registry, bookId, writingLogs).supplementOnFinish(result.chapterId, ch.wordCount)
|
||||
trackerFor(registry, bookId, writingLogs, writingSessions).supplementOnFinish(
|
||||
result.chapterId,
|
||||
ch.wordCount
|
||||
)
|
||||
scheduleChapterExtraction(registry, bookId, result.chapterId, aiClient, settings)
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import { BookPrefsRepository } from '../../db/repositories/book-prefs.repo'
|
||||
import { BookRegistryService } from '../../services/book-registry'
|
||||
import { wrap } from '../result'
|
||||
|
||||
export function registerBookPrefsHandlers(registry: BookRegistryService): void {
|
||||
ipcMain.handle(
|
||||
IPC.BOOK_PREFS_GET,
|
||||
(_event, { bookId, key }: { bookId: string; key: string }) =>
|
||||
wrap(() => new BookPrefsRepository(registry.getDb(bookId)).get(key))
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.BOOK_PREFS_SET,
|
||||
(_event, { bookId, key, value }: { bookId: string; key: string; value: string }) =>
|
||||
wrap(() => {
|
||||
new BookPrefsRepository(registry.getDb(bookId)).set(key, value)
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { ipcMain, dialog, app } from 'electron'
|
||||
import { copyFileSync, mkdirSync } from 'fs'
|
||||
import { extname, join } from 'path'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import type { BookMeta, CreateBookParams } from '../../../shared/types'
|
||||
import type { CreateBookParams, UpdateBookMetaParams } from '../../../shared/types'
|
||||
import { BookRegistryService } from '../../services/book-registry'
|
||||
import type { WritingSessionService } from '../../services/writing-session.service'
|
||||
import { wrap } from '../result'
|
||||
|
||||
export function registerBookHandlers(registry: BookRegistryService): void {
|
||||
let previousBookId: string | null = null
|
||||
|
||||
export function registerBookHandlers(
|
||||
registry: BookRegistryService,
|
||||
writingSessions?: WritingSessionService
|
||||
): void {
|
||||
ipcMain.handle(IPC.BOOK_LIST, () => wrap(() => registry.list()))
|
||||
|
||||
ipcMain.handle(IPC.BOOK_CREATE, (_event, params: CreateBookParams) =>
|
||||
@@ -18,22 +26,35 @@ export function registerBookHandlers(registry: BookRegistryService): void {
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.BOOK_OPEN, (_event, { bookId }: { bookId: string }) =>
|
||||
wrap(() => registry.open(bookId))
|
||||
wrap(() => {
|
||||
if (writingSessions && previousBookId && previousBookId !== bookId) {
|
||||
writingSessions.endActive(previousBookId)
|
||||
}
|
||||
previousBookId = bookId
|
||||
return registry.open(bookId)
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.BOOK_UPDATE_META,
|
||||
(
|
||||
_event,
|
||||
{
|
||||
bookId,
|
||||
...patch
|
||||
}: {
|
||||
bookId: string
|
||||
lastChapterId?: string | null
|
||||
lastOpenedAt?: string
|
||||
status?: BookMeta['status']
|
||||
}
|
||||
) => wrap(() => registry.updateMeta(bookId, patch))
|
||||
(_event, { bookId, ...patch }: { bookId: string } & UpdateBookMetaParams) =>
|
||||
wrap(() => registry.updateMeta(bookId, patch))
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.BOOK_PICK_COVER, (_event, { bookId }: { bookId: string }) =>
|
||||
wrap(async () => {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
filters: [{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'webp'] }],
|
||||
properties: ['openFile']
|
||||
})
|
||||
if (canceled || !filePaths[0]) return null
|
||||
const src = filePaths[0]
|
||||
const ext = extname(src).toLowerCase() || '.jpg'
|
||||
const coversDir = join(app.getPath('userData'), 'covers')
|
||||
mkdirSync(coversDir, { recursive: true })
|
||||
const dest = join(coversDir, `${bookId}${ext}`)
|
||||
copyFileSync(src, dest)
|
||||
return registry.updateMeta(bookId, { coverPath: dest })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import type { ChapterStatus, PublishStatus } from '../../../shared/types'
|
||||
import { BookRegistryService } from '../../services/book-registry'
|
||||
import { trackerFor } from '../../services/chapter-writing-tracker.service'
|
||||
import type { WritingLogService } from '../../services/writing-log.service'
|
||||
import type { WritingSessionService } from '../../services/writing-session.service'
|
||||
import { ftsSync } from '../../services/fts-sync.service'
|
||||
import { wrap } from '../result'
|
||||
|
||||
export function registerChapterHandlers(
|
||||
registry: BookRegistryService,
|
||||
writingLogs: WritingLogService
|
||||
writingLogs: WritingLogService,
|
||||
writingSessions: WritingSessionService
|
||||
): void {
|
||||
ipcMain.handle(
|
||||
IPC.VOLUME_CREATE,
|
||||
@@ -72,7 +74,13 @@ export function registerChapterHandlers(
|
||||
title,
|
||||
content,
|
||||
status,
|
||||
cursorOffset
|
||||
cursorOffset,
|
||||
povCharacterId,
|
||||
summary,
|
||||
storyTime,
|
||||
wordCountThreshold,
|
||||
publishStatus,
|
||||
publishedAt
|
||||
}: {
|
||||
bookId: string
|
||||
chapterId: string
|
||||
@@ -80,6 +88,12 @@ export function registerChapterHandlers(
|
||||
content?: string
|
||||
status?: ChapterStatus
|
||||
cursorOffset?: number
|
||||
povCharacterId?: string | null
|
||||
summary?: string
|
||||
storyTime?: string | null
|
||||
wordCountThreshold?: number | null
|
||||
publishStatus?: PublishStatus
|
||||
publishedAt?: string | null
|
||||
}
|
||||
) =>
|
||||
wrap(() => {
|
||||
@@ -87,7 +101,13 @@ export function registerChapterHandlers(
|
||||
title,
|
||||
content,
|
||||
status,
|
||||
cursorOffset
|
||||
cursorOffset,
|
||||
povCharacterId,
|
||||
summary,
|
||||
storyTime,
|
||||
wordCountThreshold,
|
||||
publishStatus,
|
||||
publishedAt
|
||||
})
|
||||
ftsSync.upsert(
|
||||
registry.getDb(bookId),
|
||||
@@ -96,7 +116,10 @@ export function registerChapterHandlers(
|
||||
chapter.title,
|
||||
chapter.content
|
||||
)
|
||||
trackerFor(registry, bookId, writingLogs).recordDelta(chapterId, chapter.wordCount)
|
||||
trackerFor(registry, bookId, writingLogs, writingSessions).recordDelta(
|
||||
chapterId,
|
||||
chapter.wordCount
|
||||
)
|
||||
registry.updateMeta(bookId, { lastChapterId: chapterId })
|
||||
return chapter
|
||||
})
|
||||
@@ -138,4 +161,34 @@ export function registerChapterHandlers(
|
||||
{ bookId, chapterId, publishStatus }: { bookId: string; chapterId: string; publishStatus: PublishStatus }
|
||||
) => wrap(() => registry.getChapterRepo(bookId).setPublishStatus(chapterId, publishStatus))
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.CHAPTER_TAG_LIST,
|
||||
(_event, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
|
||||
wrap(() => registry.getChapterTagRepo(bookId).list(chapterId))
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.CHAPTER_TAG_SET,
|
||||
(
|
||||
_event,
|
||||
{
|
||||
bookId,
|
||||
chapterId,
|
||||
tag,
|
||||
color
|
||||
}: { bookId: string; chapterId: string; tag: string; color?: string }
|
||||
) =>
|
||||
wrap(() => {
|
||||
registry.getChapterTagRepo(bookId).set(chapterId, tag, color ?? '')
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.CHAPTER_TAG_REMOVE,
|
||||
(_event, { bookId, chapterId, tag }: { bookId: string; chapterId: string; tag: string }) =>
|
||||
wrap(() => {
|
||||
registry.getChapterTagRepo(bookId).remove(chapterId, tag)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import { ComplianceService } from '../../services/compliance.service'
|
||||
import type { GlobalSettingsService } from '../../services/global-settings'
|
||||
import { wrap } from '../result'
|
||||
|
||||
export function registerComplianceHandlers(settings: GlobalSettingsService): void {
|
||||
const svc = new ComplianceService(settings)
|
||||
|
||||
ipcMain.handle(IPC.COMPLIANCE_GET_WORDS, () => wrap(() => svc.getWords()))
|
||||
|
||||
ipcMain.handle(IPC.COMPLIANCE_SET_WORDS, (_e, { words }: { words: string[] }) =>
|
||||
wrap(() => {
|
||||
svc.setWords(words)
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.COMPLIANCE_CHECK_TEXT, (_e, { text }: { text: string }) =>
|
||||
wrap(() => svc.scanText(text))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import type { ExportMatrixParams } from '../../../shared/export-matrix'
|
||||
import { ExportMatrixService } from '../../services/export-matrix.service'
|
||||
import type { BookRegistryService } from '../../services/book-registry'
|
||||
import type { GlobalSettingsService } from '../../services/global-settings'
|
||||
import { wrap } from '../result'
|
||||
|
||||
export function registerExportMatrixHandlers(
|
||||
registry: BookRegistryService,
|
||||
settings: GlobalSettingsService
|
||||
): void {
|
||||
const svc = new ExportMatrixService(registry, settings)
|
||||
|
||||
ipcMain.handle(IPC.EXPORT_MATRIX_EXPORT, (_e, params: ExportMatrixParams) =>
|
||||
wrap(() => svc.pickAndExport(params))
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { scheduleChapterExtraction } from '../../services/knowledge-extraction-r
|
||||
import { trackerFor } from '../../services/chapter-writing-tracker.service'
|
||||
import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner'
|
||||
import type { WritingLogService } from '../../services/writing-log.service'
|
||||
import type { WritingSessionService } from '../../services/writing-session.service'
|
||||
import { wrap } from '../result'
|
||||
|
||||
const activeInteractive = new Map<string, AbortController>()
|
||||
@@ -29,7 +30,8 @@ export function registerInteractiveHandlers(
|
||||
registry: BookRegistryService,
|
||||
settings: GlobalSettingsService,
|
||||
aiClient: AiClientService,
|
||||
writingLogs: WritingLogService
|
||||
writingLogs: WritingLogService,
|
||||
writingSessions: WritingSessionService
|
||||
): void {
|
||||
ipcMain.handle(IPC.INTERACTIVE_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
|
||||
wrap(() => checkInteractiveGate(registry.getDb(bookId)))
|
||||
@@ -208,7 +210,10 @@ export function registerInteractiveHandlers(
|
||||
wrap(() => {
|
||||
const result = serviceFor(registry, bookId, aiClient).finishChapter(flowId, volumeId, title)
|
||||
const ch = registry.getChapterRepo(bookId).get(result.chapterId)!
|
||||
trackerFor(registry, bookId, writingLogs).supplementOnFinish(result.chapterId, ch.wordCount)
|
||||
trackerFor(registry, bookId, writingLogs, writingSessions).supplementOnFinish(
|
||||
result.chapterId,
|
||||
ch.wordCount
|
||||
)
|
||||
scheduleChapterExtraction(registry, bookId, result.chapterId, aiClient, settings)
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -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'}` }))
|
||||
)
|
||||
}
|
||||
@@ -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]
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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]
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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())
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -10,8 +10,11 @@ import { WritingLogService } from '../services/writing-log.service'
|
||||
import { GoalNotificationService } from '../services/goal-notification.service'
|
||||
import { AchievementService } from '../services/achievement.service'
|
||||
import { PomodoroService } from '../services/pomodoro.service'
|
||||
import { WritingSessionRepository } from '../db/repositories/writing-session.repo'
|
||||
import { WritingSessionService } from '../services/writing-session.service'
|
||||
import { registerSettingsHandlers } from './handlers/settings.handler'
|
||||
import { registerBookHandlers } from './handlers/book.handler'
|
||||
import { registerBookPrefsHandlers } from './handlers/book-prefs.handler'
|
||||
import { registerChapterHandlers } from './handlers/chapter.handler'
|
||||
import { registerShortcutHandlers } from './handlers/shortcut.handler'
|
||||
import { registerOutlineHandlers } from './handlers/outline.handler'
|
||||
@@ -29,7 +32,17 @@ import { registerCockpitHandlers } from './handlers/cockpit.handler'
|
||||
import { registerWritingHandlers } from './handlers/writing.handler'
|
||||
import { registerPomodoroHandlers } from './handlers/pomodoro.handler'
|
||||
import { registerAchievementHandlers } from './handlers/achievement.handler'
|
||||
import { registerGraphHandlers } from './handlers/graph.handler'
|
||||
import { registerAnalyticsHandlers } from './handlers/analytics.handler'
|
||||
import { registerBridgeHandlers } from './handlers/bridge.handler'
|
||||
import { registerExportHandlers } from './handlers/export.handler'
|
||||
import { registerTimelineHandlers } from './handlers/timeline.handler'
|
||||
import { registerArcHandlers } from './handlers/arc.handler'
|
||||
import { registerComplianceHandlers } from './handlers/compliance.handler'
|
||||
import { registerUndoHandlers } from './handlers/undo.handler'
|
||||
import { registerLogHandlers, registerExtensionHandlers } from './handlers/log.handler'
|
||||
import { registerUpdateHandlers } from './handlers/update.handler'
|
||||
import { LogService } from '../services/log.service'
|
||||
import { AiClientService } from '../services/ai-client.service'
|
||||
import { NetworkMonitorService } from '../services/network-monitor.service'
|
||||
|
||||
@@ -37,15 +50,28 @@ let shortcutManager: ShortcutManager | null = null
|
||||
let snapshotService: SnapshotService | null = null
|
||||
let networkMonitor: NetworkMonitorService | null = null
|
||||
|
||||
export function registerIpc(): { settings: GlobalSettingsService; books: BookRegistryService; shortcuts: ShortcutManager } {
|
||||
export function registerIpc(): {
|
||||
settings: GlobalSettingsService
|
||||
books: BookRegistryService
|
||||
shortcuts: ShortcutManager
|
||||
logs: LogService
|
||||
} {
|
||||
const userData = app.getPath('userData')
|
||||
|
||||
const settings = new GlobalSettingsService(userData)
|
||||
const logs = new LogService(userData)
|
||||
if (!settings.get().onboardingCompleted) {
|
||||
const locale = app.getLocale().toLowerCase()
|
||||
const language = locale.startsWith('en') ? 'en' : 'zh-CN'
|
||||
settings.update({ language })
|
||||
}
|
||||
const books = new BookRegistryService(userData)
|
||||
const writingLogRepo = new WritingLogRepository(userData)
|
||||
const writingLogs = new WritingLogService(writingLogRepo, settings)
|
||||
const milestoneRepo = new WritingMilestoneRepository(writingLogRepo.getDb())
|
||||
const pomodoroDailyRepo = new PomodoroDailyRepository(writingLogRepo.getDb())
|
||||
const writingSessionRepo = new WritingSessionRepository(writingLogRepo.getDb())
|
||||
const writingSessions = new WritingSessionService(writingSessionRepo)
|
||||
const goalNotify = new GoalNotificationService(settings)
|
||||
const achievementService = new AchievementService(
|
||||
milestoneRepo,
|
||||
@@ -61,8 +87,9 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
|
||||
networkMonitor = new NetworkMonitorService()
|
||||
|
||||
registerSettingsHandlers(settings)
|
||||
registerBookHandlers(books)
|
||||
registerChapterHandlers(books, writingLogs)
|
||||
registerBookHandlers(books, writingSessions)
|
||||
registerBookPrefsHandlers(books)
|
||||
registerChapterHandlers(books, writingLogs, writingSessions)
|
||||
registerShortcutHandlers(shortcutManager)
|
||||
registerOutlineHandlers(books)
|
||||
registerSettingHandlers(books)
|
||||
@@ -72,17 +99,27 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
|
||||
registerSearchHandlers(books, settings)
|
||||
const aiClient = new AiClientService(() => settings.get().aiConfig)
|
||||
registerAiHandlers(books, settings, aiClient)
|
||||
registerInteractiveHandlers(books, settings, aiClient, writingLogs)
|
||||
registerAutoHandlers(books, settings, aiClient, writingLogs)
|
||||
registerInteractiveHandlers(books, settings, aiClient, writingLogs, writingSessions)
|
||||
registerAutoHandlers(books, settings, aiClient, writingLogs, writingSessions)
|
||||
registerWizardHandlers(books, settings, aiClient)
|
||||
registerKnowledgeHandlers(books, settings, aiClient)
|
||||
registerCockpitHandlers(books, settings, writingLogs, achievementService, pomodoroDailyRepo)
|
||||
registerWritingHandlers(writingLogs)
|
||||
registerPomodoroHandlers(pomodoro)
|
||||
registerAchievementHandlers(achievementService)
|
||||
registerGraphHandlers(books)
|
||||
registerAnalyticsHandlers(books, writingLogs, writingSessionRepo, pomodoroDailyRepo)
|
||||
registerBridgeHandlers(books, aiClient)
|
||||
registerExportHandlers(books, settings)
|
||||
registerTimelineHandlers(books)
|
||||
registerArcHandlers(books)
|
||||
registerComplianceHandlers(settings)
|
||||
registerUndoHandlers(books)
|
||||
registerLogHandlers(logs)
|
||||
registerExtensionHandlers()
|
||||
registerUpdateHandlers()
|
||||
|
||||
return { settings, books, shortcuts: shortcutManager }
|
||||
return { settings, books, shortcuts: shortcutManager, logs }
|
||||
}
|
||||
|
||||
export function getShortcutManager(): ShortcutManager | null {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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')
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { openBookDb } from '../db/connection'
|
||||
@@ -11,7 +11,8 @@ import { AiSessionRepository } from '../db/repositories/ai-session.repo'
|
||||
import { InteractiveRepository } from '../db/repositories/interactive.repo'
|
||||
import { SnapshotRepository } from '../db/repositories/snapshot.repo'
|
||||
import { BookmarkRepository } from '../db/repositories/bookmark.repo'
|
||||
import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume } from '../../shared/types'
|
||||
import { ChapterTagRepository } from '../db/repositories/chapter-tag.repo'
|
||||
import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume, UpdateBookMetaParams } from '../../shared/types'
|
||||
|
||||
interface RegistryFile {
|
||||
books: BookMeta[]
|
||||
@@ -49,6 +50,10 @@ export class BookRegistryService {
|
||||
return this.readRegistry().books.find((b) => b.id === bookId) ?? null
|
||||
}
|
||||
|
||||
getUserDataDir(): string {
|
||||
return this.userDataDir
|
||||
}
|
||||
|
||||
private dbPath(bookId: string): string {
|
||||
return join(this.booksDir, `${bookId}.sqlite`)
|
||||
}
|
||||
@@ -90,7 +95,45 @@ export class BookRegistryService {
|
||||
this.writeRegistry(registry)
|
||||
}
|
||||
|
||||
updateMeta(bookId: string, patch: Partial<Pick<BookMeta, 'lastOpenedAt' | 'lastChapterId' | 'status'>>): BookMeta {
|
||||
private ensureUniqueName(name: string): string {
|
||||
const names = new Set(this.list().map((b) => b.name))
|
||||
if (!names.has(name)) return name
|
||||
let i = 2
|
||||
while (names.has(`${name} (${i})`)) i += 1
|
||||
return `${name} (${i})`
|
||||
}
|
||||
|
||||
importFromPack(meta: BookMeta, sqliteSourcePath: string, coverPath: string | null): BookMeta {
|
||||
const id = randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
const imported: BookMeta = {
|
||||
...meta,
|
||||
id,
|
||||
name: this.ensureUniqueName(meta.name),
|
||||
dbPath: join('books', `${id}.sqlite`),
|
||||
coverPath: coverPath ?? meta.coverPath ?? null,
|
||||
lastOpenedAt: now,
|
||||
createdAt: meta.createdAt ?? now
|
||||
}
|
||||
copyFileSync(sqliteSourcePath, this.dbPath(id))
|
||||
const registry = this.readRegistry()
|
||||
registry.books.unshift(imported)
|
||||
this.writeRegistry(registry)
|
||||
return imported
|
||||
}
|
||||
|
||||
replaceFromPack(bookId: string, meta: BookMeta, sqliteSourcePath: string): BookMeta {
|
||||
copyFileSync(sqliteSourcePath, this.dbPath(bookId))
|
||||
return this.updateMeta(bookId, {
|
||||
name: meta.name,
|
||||
category: meta.category,
|
||||
targetWordCount: meta.targetWordCount,
|
||||
synopsis: meta.synopsis,
|
||||
status: meta.status
|
||||
})
|
||||
}
|
||||
|
||||
updateMeta(bookId: string, patch: UpdateBookMetaParams): BookMeta {
|
||||
const registry = this.readRegistry()
|
||||
const idx = registry.books.findIndex((b) => b.id === bookId)
|
||||
if (idx === -1) throw new Error('Book not found')
|
||||
@@ -162,6 +205,10 @@ export class BookRegistryService {
|
||||
getBookmarkRepo(bookId: string): BookmarkRepository {
|
||||
return new BookmarkRepository(this.getDb(bookId))
|
||||
}
|
||||
|
||||
getChapterTagRepo(bookId: string): ChapterTagRepository {
|
||||
return new ChapterTagRepository(this.getDb(bookId))
|
||||
}
|
||||
}
|
||||
|
||||
export function aggregateWordCounts(chapters: Chapter[], volumes: Volume[]): {
|
||||
|
||||
@@ -2,13 +2,15 @@ import type { SqliteDb } from '../db/types'
|
||||
import { ChapterWritingSnapshotRepository } from '../db/repositories/chapter-writing-snapshot.repo'
|
||||
import type { BookRegistryService } from './book-registry'
|
||||
import type { WritingLogService } from './writing-log.service'
|
||||
import type { WritingSessionService } from './writing-session.service'
|
||||
|
||||
export function trackerFor(
|
||||
registry: BookRegistryService,
|
||||
bookId: string,
|
||||
writingLogs: WritingLogService
|
||||
writingLogs: WritingLogService,
|
||||
sessions: WritingSessionService
|
||||
): ChapterWritingTracker {
|
||||
return new ChapterWritingTracker(registry.getDb(bookId), bookId, writingLogs)
|
||||
return new ChapterWritingTracker(registry.getDb(bookId), bookId, writingLogs, sessions)
|
||||
}
|
||||
|
||||
export class ChapterWritingTracker {
|
||||
@@ -17,7 +19,8 @@ export class ChapterWritingTracker {
|
||||
constructor(
|
||||
bookDb: SqliteDb,
|
||||
private bookId: string,
|
||||
private writingLogs: WritingLogService
|
||||
private writingLogs: WritingLogService,
|
||||
private sessions: WritingSessionService
|
||||
) {
|
||||
this.snapRepo = new ChapterWritingSnapshotRepository(bookDb)
|
||||
}
|
||||
@@ -26,7 +29,10 @@ export class ChapterWritingTracker {
|
||||
const snap = this.snapRepo.get(chapterId)
|
||||
const logged = snap?.loggedWordCount ?? 0
|
||||
const delta = newWordCount - logged
|
||||
if (delta !== 0) this.writingLogs.addToday(this.bookId, delta)
|
||||
if (delta !== 0) {
|
||||
this.writingLogs.addToday(this.bookId, delta)
|
||||
this.sessions.recordActivity(this.bookId, chapterId, delta)
|
||||
}
|
||||
this.snapRepo.upsert(chapterId, newWordCount, snap?.finishSupplemented ?? false)
|
||||
}
|
||||
|
||||
@@ -34,7 +40,10 @@ export class ChapterWritingTracker {
|
||||
const snap = this.snapRepo.get(chapterId)
|
||||
const logged = snap?.loggedWordCount ?? 0
|
||||
const gap = wordCount - logged
|
||||
if (gap > 0) this.writingLogs.addToday(this.bookId, gap)
|
||||
if (gap > 0) {
|
||||
this.writingLogs.addToday(this.bookId, gap)
|
||||
this.sessions.recordActivity(this.bookId, chapterId, gap)
|
||||
}
|
||||
this.snapRepo.upsert(chapterId, wordCount, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import type {
|
||||
CharacterGraphData,
|
||||
CharacterGraphEdge,
|
||||
CharacterRelationship,
|
||||
SettingEntry
|
||||
} from '../../shared/types'
|
||||
import { SettingRepository } from '../db/repositories/setting.repo'
|
||||
import type { BookRegistryService } from './book-registry'
|
||||
|
||||
const MAX_NODES = 100
|
||||
|
||||
function parseRelationships(entry: SettingEntry): CharacterRelationship[] {
|
||||
const raw = entry.properties?.relationships
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.filter(
|
||||
(r): r is CharacterRelationship =>
|
||||
r != null &&
|
||||
typeof r === 'object' &&
|
||||
typeof (r as CharacterRelationship).id === 'string' &&
|
||||
typeof (r as CharacterRelationship).targetId === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function dedupeEdges(
|
||||
raw: Array<{ source: string; target: string; label: string; intimacy: number; id: string }>
|
||||
): CharacterGraphEdge[] {
|
||||
const best = new Map<string, CharacterGraphEdge>()
|
||||
for (const e of raw) {
|
||||
const key = [e.source, e.target].sort().join('|')
|
||||
const existing = best.get(key)
|
||||
if (!existing || e.intimacy > existing.intimacy) {
|
||||
best.set(key, {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: e.label,
|
||||
intimacy: e.intimacy as 1 | 2 | 3 | 4 | 5
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...best.values()]
|
||||
}
|
||||
|
||||
export class CharacterGraphService {
|
||||
constructor(private registry: BookRegistryService) {}
|
||||
|
||||
getData(bookId: string, filter: 'all' | 'connected' = 'all'): CharacterGraphData {
|
||||
const repo = new SettingRepository(this.registry.getDb(bookId))
|
||||
const characters = repo.list('character')
|
||||
const nodeCount = characters.length
|
||||
const truncated = nodeCount > MAX_NODES
|
||||
|
||||
let nodes = characters.map((c) => ({ id: c.id, label: c.name }))
|
||||
const rawEdges: Array<{
|
||||
source: string
|
||||
target: string
|
||||
label: string
|
||||
intimacy: number
|
||||
id: string
|
||||
}> = []
|
||||
|
||||
for (const c of characters) {
|
||||
for (const rel of parseRelationships(c)) {
|
||||
if (!characters.some((x) => x.id === rel.targetId)) continue
|
||||
rawEdges.push({
|
||||
source: c.id,
|
||||
target: rel.targetId,
|
||||
label: rel.label,
|
||||
intimacy: rel.intimacy,
|
||||
id: rel.id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let edges = dedupeEdges(rawEdges)
|
||||
|
||||
if (filter === 'connected') {
|
||||
const connected = new Set<string>()
|
||||
for (const e of edges) {
|
||||
connected.add(e.source)
|
||||
connected.add(e.target)
|
||||
}
|
||||
nodes = nodes.filter((n) => connected.has(n.id))
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
nodes = nodes.slice(0, MAX_NODES)
|
||||
const allowed = new Set(nodes.map((n) => n.id))
|
||||
edges = edges.filter((e) => allowed.has(e.source) && allowed.has(e.target))
|
||||
}
|
||||
|
||||
return { nodes, edges, truncated, nodeCount }
|
||||
}
|
||||
|
||||
upsertRelationship(
|
||||
bookId: string,
|
||||
sourceId: string,
|
||||
input: Partial<CharacterRelationship> & {
|
||||
targetId: string
|
||||
label: string
|
||||
intimacy: number
|
||||
}
|
||||
): CharacterRelationship {
|
||||
const repo = new SettingRepository(this.registry.getDb(bookId))
|
||||
const source = repo.get(sourceId)
|
||||
const target = repo.get(input.targetId)
|
||||
if (!source || source.type !== 'character') throw new Error('Invalid source character')
|
||||
if (!target || target.type !== 'character') throw new Error('Invalid target character')
|
||||
if (sourceId === input.targetId) throw new Error('Cannot relate character to itself')
|
||||
|
||||
const intimacy = Math.min(5, Math.max(1, Math.round(input.intimacy))) as CharacterRelationship['intimacy']
|
||||
const relationships = parseRelationships(source)
|
||||
const id = input.id ?? randomUUID()
|
||||
const next: CharacterRelationship = {
|
||||
id,
|
||||
targetId: input.targetId,
|
||||
label: input.label.trim(),
|
||||
intimacy
|
||||
}
|
||||
const idx = relationships.findIndex((r) => r.id === id)
|
||||
if (idx >= 0) relationships[idx] = next
|
||||
else relationships.push(next)
|
||||
|
||||
repo.update(sourceId, {
|
||||
properties: { ...source.properties, relationships }
|
||||
})
|
||||
return next
|
||||
}
|
||||
|
||||
deleteRelationship(bookId: string, sourceId: string, relationshipId: string): void {
|
||||
const repo = new SettingRepository(this.registry.getDb(bookId))
|
||||
const source = repo.get(sourceId)
|
||||
if (!source) throw new Error('Setting not found')
|
||||
const relationships = parseRelationships(source).filter((r) => r.id !== relationshipId)
|
||||
repo.update(sourceId, {
|
||||
properties: { ...source.properties, relationships }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,9 @@ export class CockpitService {
|
||||
}
|
||||
|
||||
shouldShowOnOpen(): boolean {
|
||||
return new BookPrefsRepository(this.db).get('cockpitSeen') !== 'true'
|
||||
const prefs = new BookPrefsRepository(this.db)
|
||||
if (prefs.get('alwaysShowCockpit') === 'true') return true
|
||||
return prefs.get('cockpitSeen') !== 'true'
|
||||
}
|
||||
|
||||
markSeen(): void {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -35,7 +35,16 @@ function defaults(): GlobalSettings {
|
||||
knowledgeExtractConfidenceThreshold: 0.8,
|
||||
pomodoroDurationMinutes: 25,
|
||||
enableSystemNotifications: false,
|
||||
enableGoalNotifications: true
|
||||
enableGoalNotifications: true,
|
||||
chapterTemplates: [],
|
||||
submissionPresets: [],
|
||||
defaultSubmissionPresetId: 'builtin-webnovel',
|
||||
enableComplianceCheck: false,
|
||||
complianceWords: [],
|
||||
customSlashCommands: [],
|
||||
syncConfig: null,
|
||||
extensionDevMode: false,
|
||||
mcpConnectorUrl: ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +73,16 @@ export class GlobalSettingsService {
|
||||
enableSystemNotifications: raw.enableSystemNotifications ?? false,
|
||||
enableGoalNotifications: raw.enableGoalNotifications ?? true,
|
||||
dailyGoalNotifiedDate: raw.dailyGoalNotifiedDate,
|
||||
chapterTemplates: raw.chapterTemplates ?? [],
|
||||
submissionPresets: raw.submissionPresets ?? [],
|
||||
defaultSubmissionPresetId: raw.defaultSubmissionPresetId ?? 'builtin-webnovel',
|
||||
enableComplianceCheck: raw.enableComplianceCheck ?? false,
|
||||
complianceWords: raw.complianceWords ?? [],
|
||||
customSlashCommands: raw.customSlashCommands ?? [],
|
||||
syncConfig: raw.syncConfig ?? null,
|
||||
syncPasswordVerifier: raw.syncPasswordVerifier,
|
||||
extensionDevMode: raw.extensionDevMode ?? false,
|
||||
mcpConnectorUrl: raw.mcpConnectorUrl ?? '',
|
||||
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
|
||||
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig }
|
||||
}
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,10 @@ export class WritingLogService {
|
||||
return { todayWords, dailyGoal, goalProgress, streakDays, heatmap }
|
||||
}
|
||||
|
||||
getDayWords(bookId: string, date: string): number {
|
||||
return this.repo.getDay(bookId, date)
|
||||
}
|
||||
|
||||
private calcStreak(bookId: string, goal: number): number {
|
||||
let streak = 0
|
||||
let date = addDays(localDateString(), -1)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { WritingSessionRepository } from '../db/repositories/writing-session.repo'
|
||||
import { localDateString } from './writing-date.util'
|
||||
|
||||
const IDLE_MS = 5 * 60 * 1000
|
||||
|
||||
export class WritingSessionService {
|
||||
private active = new Map<string, { sessionId: string; lastEndedAt: number }>()
|
||||
|
||||
constructor(private repo: WritingSessionRepository) {}
|
||||
|
||||
recordActivity(bookId: string, chapterId: string | undefined, delta: number): void {
|
||||
if (delta === 0) return
|
||||
const now = new Date()
|
||||
const nowIso = now.toISOString()
|
||||
const cached = this.active.get(bookId)
|
||||
const idle = !cached || now.getTime() - cached.lastEndedAt > IDLE_MS
|
||||
|
||||
if (idle) {
|
||||
const id = randomUUID()
|
||||
this.repo.insert({
|
||||
id,
|
||||
bookId,
|
||||
chapterId,
|
||||
startedAt: nowIso,
|
||||
endedAt: nowIso,
|
||||
wordDelta: delta,
|
||||
date: localDateString()
|
||||
})
|
||||
this.active.set(bookId, { sessionId: id, lastEndedAt: now.getTime() })
|
||||
} else {
|
||||
this.repo.updateActivity(cached!.sessionId, nowIso, delta)
|
||||
this.active.set(bookId, { sessionId: cached!.sessionId, lastEndedAt: now.getTime() })
|
||||
}
|
||||
}
|
||||
|
||||
endActive(bookId: string): void {
|
||||
this.active.delete(bookId)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -36,6 +36,8 @@ import type {
|
||||
Snapshot,
|
||||
SnapshotType,
|
||||
UpdateChapterParams,
|
||||
UpdateBookMetaParams,
|
||||
ChapterTag,
|
||||
Volume,
|
||||
WordFreqResult,
|
||||
KnowledgeEntry,
|
||||
@@ -50,8 +52,32 @@ import type {
|
||||
PomodoroState,
|
||||
GoalNotificationPayload,
|
||||
KnowledgeInjectionLog,
|
||||
WritingAchievement
|
||||
WritingAchievement,
|
||||
CharacterGraphData,
|
||||
CharacterRelationship,
|
||||
WritingAnalyticsSummary,
|
||||
ImportPreviewResult,
|
||||
ImportExecuteParams,
|
||||
ImportSplitMode
|
||||
} from '../shared/types'
|
||||
import type {
|
||||
PackImportStrategy,
|
||||
PackImportReport,
|
||||
PackProgressEvent
|
||||
} from '../shared/novel-pack'
|
||||
import type { ExportMatrixParams } from '../shared/export-matrix'
|
||||
import type { TimelineEntry, TimelineConflict, TimelineUpsertManualInput } from '../shared/timeline'
|
||||
import type { CharacterArcNode } from '../shared/arc'
|
||||
import type { ComplianceScanResult } from '../shared/compliance'
|
||||
import type { UndoOperation, UndoStackEntry } from '../shared/undo'
|
||||
import type {
|
||||
SyncConfig,
|
||||
SyncConfigureInput,
|
||||
SyncProgressEvent,
|
||||
SyncRunInput,
|
||||
SyncRunResult
|
||||
} from '../shared/sync'
|
||||
import type { ExtensionManifestEntry } from '../shared/extension-api'
|
||||
|
||||
const electronAPI = {
|
||||
window: {
|
||||
@@ -74,8 +100,16 @@ const electronAPI = {
|
||||
ipcRenderer.invoke(IPC.BOOK_OPEN, { bookId }),
|
||||
updateMeta: (
|
||||
bookId: string,
|
||||
patch: { lastChapterId?: string | null; status?: BookMeta['status'] }
|
||||
): Promise<IpcResult<BookMeta>> => ipcRenderer.invoke(IPC.BOOK_UPDATE_META, { bookId, ...patch })
|
||||
patch: UpdateBookMetaParams
|
||||
): Promise<IpcResult<BookMeta>> => ipcRenderer.invoke(IPC.BOOK_UPDATE_META, { bookId, ...patch }),
|
||||
pickCover: (bookId: string): Promise<IpcResult<BookMeta | null>> =>
|
||||
ipcRenderer.invoke(IPC.BOOK_PICK_COVER, { bookId })
|
||||
},
|
||||
bookPrefs: {
|
||||
get: (bookId: string, key: string): Promise<IpcResult<string | null>> =>
|
||||
ipcRenderer.invoke(IPC.BOOK_PREFS_GET, { bookId, key }),
|
||||
set: (bookId: string, key: string, value: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.BOOK_PREFS_SET, { bookId, key, value })
|
||||
},
|
||||
volume: {
|
||||
create: (bookId: string, name: string): Promise<IpcResult<Volume>> =>
|
||||
@@ -114,6 +148,19 @@ const electronAPI = {
|
||||
): Promise<IpcResult<Chapter>> =>
|
||||
ipcRenderer.invoke(IPC.CHAPTER_SET_PUBLISH_STATUS, { bookId, chapterId, publishStatus })
|
||||
},
|
||||
chapterTag: {
|
||||
list: (bookId: string, chapterId: string): Promise<IpcResult<ChapterTag[]>> =>
|
||||
ipcRenderer.invoke(IPC.CHAPTER_TAG_LIST, { bookId, chapterId }),
|
||||
set: (
|
||||
bookId: string,
|
||||
chapterId: string,
|
||||
tag: string,
|
||||
color?: string
|
||||
): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.CHAPTER_TAG_SET, { bookId, chapterId, tag, color }),
|
||||
remove: (bookId: string, chapterId: string, tag: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.CHAPTER_TAG_REMOVE, { bookId, chapterId, tag })
|
||||
},
|
||||
outline: {
|
||||
list: (bookId: string): Promise<IpcResult<OutlineItem[]>> =>
|
||||
ipcRenderer.invoke(IPC.OUTLINE_LIST, { bookId }),
|
||||
@@ -271,6 +318,12 @@ const electronAPI = {
|
||||
ipcRenderer.invoke(IPC.AI_SESSION_UPDATE, { bookId, sessionId, patch }),
|
||||
sessionDelete: (bookId: string, sessionId: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.AI_SESSION_DELETE, { bookId, sessionId }),
|
||||
sessionExport: (
|
||||
bookId: string,
|
||||
sessionId: string,
|
||||
format?: 'markdown' | 'txt'
|
||||
): Promise<IpcResult<string | null>> =>
|
||||
ipcRenderer.invoke(IPC.AI_SESSION_EXPORT, { bookId, sessionId, format }),
|
||||
messageList: (bookId: string, sessionId: string): Promise<IpcResult<AiMessage[]>> =>
|
||||
ipcRenderer.invoke(IPC.AI_MESSAGE_LIST, { bookId, sessionId }),
|
||||
chat: (
|
||||
@@ -513,6 +566,146 @@ const electronAPI = {
|
||||
shouldShowOnOpen: (bookId: string): Promise<IpcResult<boolean>> =>
|
||||
ipcRenderer.invoke(IPC.COCKPIT_SHOULD_SHOW, { bookId })
|
||||
},
|
||||
graph: {
|
||||
getData: (
|
||||
bookId: string,
|
||||
filter?: 'all' | 'connected'
|
||||
): Promise<IpcResult<CharacterGraphData>> =>
|
||||
ipcRenderer.invoke(IPC.GRAPH_GET_DATA, { bookId, filter }),
|
||||
upsertRelationship: (
|
||||
bookId: string,
|
||||
sourceId: string,
|
||||
relationship: Partial<CharacterRelationship> & {
|
||||
targetId: string
|
||||
label: string
|
||||
intimacy: number
|
||||
}
|
||||
): Promise<IpcResult<CharacterRelationship>> =>
|
||||
ipcRenderer.invoke(IPC.GRAPH_UPSERT_RELATIONSHIP, { bookId, sourceId, relationship }),
|
||||
deleteRelationship: (
|
||||
bookId: string,
|
||||
sourceId: string,
|
||||
relationshipId: string
|
||||
): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.GRAPH_DELETE_RELATIONSHIP, { bookId, sourceId, relationshipId })
|
||||
},
|
||||
analytics: {
|
||||
getSummary: (bookId: string): Promise<IpcResult<WritingAnalyticsSummary>> =>
|
||||
ipcRenderer.invoke(IPC.ANALYTICS_SUMMARY, { bookId })
|
||||
},
|
||||
import: {
|
||||
pickFile: (): Promise<IpcResult<{ canceled: boolean; filePath?: string }>> =>
|
||||
ipcRenderer.invoke(IPC.IMPORT_PICK_FILE),
|
||||
preview: (
|
||||
filePath: string,
|
||||
splitMode: ImportSplitMode,
|
||||
customRegex?: string
|
||||
): Promise<IpcResult<ImportPreviewResult>> =>
|
||||
ipcRenderer.invoke(IPC.IMPORT_PREVIEW, { filePath, splitMode, customRegex }),
|
||||
execute: (params: ImportExecuteParams): Promise<IpcResult<string>> =>
|
||||
ipcRenderer.invoke(IPC.IMPORT_EXECUTE, params)
|
||||
},
|
||||
export: {
|
||||
formatChapter: (
|
||||
bookId: string,
|
||||
chapterId: string,
|
||||
presetId: string
|
||||
): Promise<IpcResult<string>> =>
|
||||
ipcRenderer.invoke(IPC.EXPORT_FORMAT_CHAPTER, { bookId, chapterId, presetId }),
|
||||
copyClipboard: (text: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.EXPORT_COPY_CLIPBOARD, { text }),
|
||||
saveTxt: (text: string, defaultName: string): Promise<IpcResult<string | null>> =>
|
||||
ipcRenderer.invoke(IPC.EXPORT_SAVE_TXT, { text, defaultName })
|
||||
},
|
||||
exportMatrix: {
|
||||
export: (params: ExportMatrixParams): Promise<IpcResult<string | null>> =>
|
||||
ipcRenderer.invoke(IPC.EXPORT_MATRIX_EXPORT, params)
|
||||
},
|
||||
timeline: {
|
||||
list: (bookId: string): Promise<IpcResult<TimelineEntry[]>> =>
|
||||
ipcRenderer.invoke(IPC.TIMELINE_LIST, { bookId }),
|
||||
upsert: (
|
||||
bookId: string,
|
||||
input: TimelineUpsertManualInput
|
||||
): Promise<IpcResult<TimelineEntry>> => ipcRenderer.invoke(IPC.TIMELINE_UPSERT, { bookId, input }),
|
||||
delete: (bookId: string, id: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.TIMELINE_DELETE, { bookId, id }),
|
||||
conflicts: (bookId: string): Promise<IpcResult<TimelineConflict[]>> =>
|
||||
ipcRenderer.invoke(IPC.TIMELINE_CONFLICTS, { bookId })
|
||||
},
|
||||
arc: {
|
||||
getForCharacter: (bookId: string, settingId: string): Promise<IpcResult<CharacterArcNode[]>> =>
|
||||
ipcRenderer.invoke(IPC.ARC_GET_FOR_CHARACTER, { bookId, settingId })
|
||||
},
|
||||
compliance: {
|
||||
getWords: (): Promise<IpcResult<string[]>> => ipcRenderer.invoke(IPC.COMPLIANCE_GET_WORDS),
|
||||
setWords: (words: string[]): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.COMPLIANCE_SET_WORDS, { words }),
|
||||
checkText: (text: string): Promise<IpcResult<ComplianceScanResult>> =>
|
||||
ipcRenderer.invoke(IPC.COMPLIANCE_CHECK_TEXT, { text })
|
||||
},
|
||||
undo: {
|
||||
push: (
|
||||
bookId: string,
|
||||
chapterId: string,
|
||||
operation: UndoOperation
|
||||
): Promise<IpcResult<UndoStackEntry>> =>
|
||||
ipcRenderer.invoke(IPC.UNDO_PUSH, { bookId, chapterId, operation }),
|
||||
list: (bookId: string, chapterId: string): Promise<IpcResult<UndoStackEntry[]>> =>
|
||||
ipcRenderer.invoke(IPC.UNDO_LIST, { bookId, chapterId }),
|
||||
apply: (bookId: string, entryId: number): Promise<IpcResult<UndoOperation>> =>
|
||||
ipcRenderer.invoke(IPC.UNDO_APPLY, { bookId, entryId })
|
||||
},
|
||||
sync: {
|
||||
status: (): Promise<IpcResult<SyncConfig | null>> => ipcRenderer.invoke(IPC.SYNC_STATUS),
|
||||
configure: (input: SyncConfigureInput): Promise<IpcResult<SyncConfig>> =>
|
||||
ipcRenderer.invoke(IPC.SYNC_CONFIGURE, input),
|
||||
run: (input: SyncRunInput): Promise<IpcResult<SyncRunResult>> =>
|
||||
ipcRenderer.invoke(IPC.SYNC_RUN, input),
|
||||
pickFolder: (): Promise<IpcResult<string | null>> => ipcRenderer.invoke(IPC.SYNC_PICK_FOLDER)
|
||||
},
|
||||
update: {
|
||||
check: (): Promise<IpcResult<{ version: string; releaseNotes?: string } | null>> =>
|
||||
ipcRenderer.invoke(IPC.UPDATE_CHECK),
|
||||
download: (): Promise<IpcResult<void>> => ipcRenderer.invoke(IPC.UPDATE_DOWNLOAD),
|
||||
install: (): Promise<IpcResult<void>> => ipcRenderer.invoke(IPC.UPDATE_INSTALL)
|
||||
},
|
||||
log: {
|
||||
hadCrash: (): Promise<IpcResult<boolean>> => ipcRenderer.invoke(IPC.LOG_HAD_CRASH),
|
||||
clearCrash: (): Promise<IpcResult<void>> => ipcRenderer.invoke(IPC.LOG_CLEAR_CRASH),
|
||||
sendReport: (note?: string): Promise<IpcResult<{ ok: true; loggedAt: string }>> =>
|
||||
ipcRenderer.invoke(IPC.LOG_SEND_REPORT, { note }),
|
||||
writeError: (message: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.LOG_WRITE_ERROR, { message })
|
||||
},
|
||||
extension: {
|
||||
list: (): Promise<IpcResult<ExtensionManifestEntry[]>> => ipcRenderer.invoke(IPC.EXTENSION_LIST),
|
||||
validate: (manifest: unknown): Promise<IpcResult<ExtensionManifestEntry>> =>
|
||||
ipcRenderer.invoke(IPC.EXTENSION_VALIDATE, { manifest })
|
||||
},
|
||||
mcp: {
|
||||
testConnection: (url: string): Promise<IpcResult<{ ok: boolean; message: string }>> =>
|
||||
ipcRenderer.invoke(IPC.MCP_TEST_CONNECTION, { url })
|
||||
},
|
||||
pack: {
|
||||
pickFile: (
|
||||
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
|
||||
): Promise<IpcResult<string | null>> => ipcRenderer.invoke(IPC.PACK_PICK_FILE, { mode }),
|
||||
exportBook: (bookId: string, destPath: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.PACK_EXPORT_BOOK, { bookId, destPath }),
|
||||
importBook: (filePath: string): Promise<IpcResult<string>> =>
|
||||
ipcRenderer.invoke(IPC.PACK_IMPORT_BOOK, { filePath }),
|
||||
exportAll: (destPath: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.PACK_EXPORT_ALL, { destPath }),
|
||||
importAll: (
|
||||
filePath: string,
|
||||
strategy: PackImportStrategy
|
||||
): Promise<IpcResult<PackImportReport>> =>
|
||||
ipcRenderer.invoke(IPC.PACK_IMPORT_ALL, { filePath, strategy }),
|
||||
estimateExportAll: (): Promise<IpcResult<{ bytes: number; needsConfirm: boolean }>> =>
|
||||
ipcRenderer.invoke(IPC.PACK_ESTIMATE_EXPORT_ALL),
|
||||
cancel: (): Promise<IpcResult<void>> => ipcRenderer.invoke(IPC.PACK_CANCEL)
|
||||
},
|
||||
bridge: {
|
||||
get: (
|
||||
bookId: string,
|
||||
@@ -579,6 +772,60 @@ const electronAPI = {
|
||||
}
|
||||
ipcRenderer.on(IPC.GOAL_NOTIFICATION, handler)
|
||||
return () => ipcRenderer.removeListener(IPC.GOAL_NOTIFICATION, handler)
|
||||
},
|
||||
onImportProgress: (
|
||||
callback: (payload: { current: number; total: number }) => void
|
||||
): (() => void) => {
|
||||
const handler = (_event: IpcRendererEvent, payload: { current: number; total: number }) => {
|
||||
callback(payload)
|
||||
}
|
||||
ipcRenderer.on(IPC.IMPORT_PROGRESS, handler)
|
||||
return () => ipcRenderer.removeListener(IPC.IMPORT_PROGRESS, handler)
|
||||
},
|
||||
onPackProgress: (callback: (payload: PackProgressEvent) => void): (() => void) => {
|
||||
const handler = (_event: IpcRendererEvent, payload: PackProgressEvent) => {
|
||||
callback(payload)
|
||||
}
|
||||
ipcRenderer.on(IPC.PACK_PROGRESS, handler)
|
||||
return () => ipcRenderer.removeListener(IPC.PACK_PROGRESS, handler)
|
||||
},
|
||||
onSyncProgress: (callback: (payload: SyncProgressEvent) => void): (() => void) => {
|
||||
const handler = (_event: IpcRendererEvent, payload: SyncProgressEvent) => {
|
||||
callback(payload)
|
||||
}
|
||||
ipcRenderer.on(IPC.SYNC_PROGRESS, handler)
|
||||
return () => ipcRenderer.removeListener(IPC.SYNC_PROGRESS, handler)
|
||||
},
|
||||
onUpdateAvailable: (
|
||||
callback: (payload: { version: string; releaseNotes?: string }) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: IpcRendererEvent,
|
||||
payload: { version: string; releaseNotes?: string }
|
||||
) => {
|
||||
callback(payload)
|
||||
}
|
||||
ipcRenderer.on(IPC.UPDATE_AVAILABLE, handler)
|
||||
return () => ipcRenderer.removeListener(IPC.UPDATE_AVAILABLE, handler)
|
||||
},
|
||||
onUpdateProgress: (
|
||||
callback: (payload: { percent: number; transferred: number; total: number }) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: IpcRendererEvent,
|
||||
payload: { percent: number; transferred: number; total: number }
|
||||
) => {
|
||||
callback(payload)
|
||||
}
|
||||
ipcRenderer.on(IPC.UPDATE_PROGRESS, handler)
|
||||
return () => ipcRenderer.removeListener(IPC.UPDATE_PROGRESS, handler)
|
||||
},
|
||||
onUpdateDownloaded: (callback: (payload: { version: string }) => void): (() => void) => {
|
||||
const handler = (_event: IpcRendererEvent, payload: { version: string }) => {
|
||||
callback(payload)
|
||||
}
|
||||
ipcRenderer.on(IPC.UPDATE_DOWNLOADED, handler)
|
||||
return () => ipcRenderer.removeListener(IPC.UPDATE_DOWNLOADED, handler)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+60
-6
@@ -6,6 +6,7 @@ import './styles/themes.css'
|
||||
import './styles/globals.css'
|
||||
import './styles/layout.css'
|
||||
import { TopBar } from '@renderer/components/layout/TopBar'
|
||||
import { UpdateBanner } from '@renderer/components/settings/UpdateBanner'
|
||||
import { HomePage } from '@renderer/components/home/HomePage'
|
||||
import { EditorLayout } from '@renderer/components/layout/EditorLayout'
|
||||
import { SettingsPage } from '@renderer/components/settings/SettingsPage'
|
||||
@@ -17,17 +18,31 @@ import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { SearchModal } from '@renderer/components/search/SearchModal'
|
||||
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal'
|
||||
import { ImportBookModal } from '@renderer/components/import/ImportBookModal'
|
||||
import { ExportAllModal } from '@renderer/components/export/ExportAllModal'
|
||||
import { ReadModeOverlay } from '@renderer/components/read/ReadModeOverlay'
|
||||
import { TimelineModal } from '@renderer/components/timeline/TimelineModal'
|
||||
import { FocusModeOverlay } from '@renderer/components/focus/FocusModeOverlay'
|
||||
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
|
||||
import { useSearchStore } from '@renderer/stores/useSearchStore'
|
||||
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
|
||||
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||
import { getEditorTextForTts } from '@renderer/lib/editor-commands'
|
||||
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
|
||||
import { isSpeaking, speakText, stopSpeaking } from '@renderer/lib/tts-controller'
|
||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||
import { useExportStore } from '@renderer/stores/useExportStore'
|
||||
import { CrashReportDialog } from '@renderer/components/settings/CrashReportDialog'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
function AppInner(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const view = useAppStore((s) => s.view)
|
||||
const toast = useAppStore((s) => s.toast)
|
||||
const focusMode = useAppStore((s) => s.focusMode)
|
||||
const namingCheckOpen = useAppStore((s) => s.namingCheckOpen)
|
||||
const setFocusMode = useAppStore((s) => s.setFocusMode)
|
||||
const setNamingCheckOpen = useAppStore((s) => s.setNamingCheckOpen)
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const setView = useAppStore((s) => s.setView)
|
||||
const { loaded, onboardingCompleted, load: loadSettings } = useSettingsStore()
|
||||
@@ -35,6 +50,13 @@ function AppInner(): React.JSX.Element {
|
||||
const { activeTabId, openTabs } = useTabStore()
|
||||
const openBook = useBookStore((s) => s.openBook)
|
||||
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||
const [crashDialogOpen, setCrashDialogOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void ipcCall(() => window.electronAPI.log.hadCrash()).then((had) => {
|
||||
if (had) setCrashDialogOpen(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
@@ -57,8 +79,6 @@ function AppInner(): React.JSX.Element {
|
||||
const openSearch = (): void => useSearchStore.getState().setOpen(true)
|
||||
const openLandmarks = (): void => useAppStore.getState().setLandmarksModalOpen(true)
|
||||
const openInspiration = (): void => {
|
||||
if (useAppStore.getState().view !== 'editor') return
|
||||
if (!useBookStore.getState().currentBookId) return
|
||||
useAppStore.getState().setInspirationModalOpen(true)
|
||||
}
|
||||
const onInsertLandmark = (e: Event): void => {
|
||||
@@ -148,31 +168,65 @@ function AppInner(): React.JSX.Element {
|
||||
return
|
||||
}
|
||||
if (action === 'captureInspiration') {
|
||||
if (view !== 'editor') return
|
||||
if (!useBookStore.getState().currentBookId) return
|
||||
useAppStore.getState().setInspirationModalOpen(true)
|
||||
return
|
||||
}
|
||||
if (action === 'exportBook') {
|
||||
if (view !== 'editor') return
|
||||
const target = useEditStore.getState().target
|
||||
if (target?.kind !== 'chapter') return
|
||||
useExportStore.getState().openModal()
|
||||
return
|
||||
}
|
||||
if (action === 'focusMode') {
|
||||
if (view !== 'editor') return
|
||||
setFocusMode(!useAppStore.getState().focusMode)
|
||||
return
|
||||
}
|
||||
if (action === 'toggleTTS') {
|
||||
if (view !== 'editor') return
|
||||
if (isSpeaking()) {
|
||||
stopSpeaking()
|
||||
showToast(t('tts.stopped'))
|
||||
return
|
||||
}
|
||||
const text = getEditorTextForTts()
|
||||
if (!text.trim()) {
|
||||
showToast(t('tts.noText'))
|
||||
return
|
||||
}
|
||||
speakText(text)
|
||||
showToast(t('tts.started'))
|
||||
return
|
||||
}
|
||||
showToast(t('feature.comingSoon'))
|
||||
})
|
||||
return unsub
|
||||
}, [view, activeTabId, openTabs, openBook, setView, showToast, t])
|
||||
}, [view, activeTabId, openTabs, openBook, setView, setFocusMode, showToast, t])
|
||||
|
||||
const handleOnboardingComplete = (): void => {
|
||||
setShowOnboarding(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="app">
|
||||
<div id="app" className={focusMode ? 'focus-mode' : undefined} data-testid="app-root">
|
||||
<TopBar />
|
||||
<UpdateBanner />
|
||||
<div id="main-area">
|
||||
{view === 'home' && <HomePage />}
|
||||
{view === 'editor' && <EditorLayout />}
|
||||
{view === 'settings' && <SettingsPage />}
|
||||
</div>
|
||||
<FocusModeOverlay />
|
||||
<ReadModeOverlay />
|
||||
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
|
||||
<SearchModal />
|
||||
<InspirationModal />
|
||||
<ImportBookModal />
|
||||
<ExportAllModal />
|
||||
<TimelineModal />
|
||||
<CrashReportDialog open={crashDialogOpen} onClose={() => setCrashDialogOpen(false)} />
|
||||
<NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} />
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,12 +6,14 @@ import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { applySlashCommand } from '@renderer/lib/ai-slash-commands'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { insertToEditor } from '@renderer/lib/editor-commands'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
|
||||
import { InteractivePanel } from '@renderer/components/ai/InteractivePanel'
|
||||
import { AutoPanel } from '@renderer/components/ai/AutoPanel'
|
||||
import { WizardPanel } from '@renderer/components/ai/WizardPanel'
|
||||
import { AiSessionMenu } from '@renderer/components/ai/AiSessionMenu'
|
||||
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
|
||||
import { useAutoStore } from '@renderer/stores/useAutoStore'
|
||||
import { useWizardStore } from '@renderer/stores/useWizardStore'
|
||||
@@ -28,6 +30,7 @@ const QUICK_CMDS = ['/续写', '/扩写', '/润色', '/总结', '/纠错']
|
||||
export function AiPanel(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const customSlashCommands = useSettingsStore((s) => s.customSlashCommands)
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const sessions = useAiStore((s) => s.sessions)
|
||||
const activeSessionId = useAiStore((s) => s.activeSessionId)
|
||||
@@ -75,7 +78,7 @@ export function AiPanel(): React.JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
if (bookId) {
|
||||
void loadSessions(bookId)
|
||||
void loadSessions(bookId, true)
|
||||
void checkGate(bookId)
|
||||
void checkAutoGate(bookId)
|
||||
void checkWizardGate(bookId)
|
||||
@@ -89,7 +92,7 @@ export function AiPanel(): React.JSX.Element {
|
||||
const handleSend = (): void => {
|
||||
const text = input.trim()
|
||||
if (!text || sending || disabled) return
|
||||
const { display, prompt } = applySlashCommand(text, t)
|
||||
const { display, prompt } = applySlashCommand(text, t, customSlashCommands)
|
||||
setInput('')
|
||||
void sendMessage(display, prompt !== display ? prompt : undefined)
|
||||
}
|
||||
@@ -176,6 +179,10 @@ export function AiPanel(): React.JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
const activeSessions = sessions.filter((s) => !s.archived)
|
||||
const archivedSessions = sessions.filter((s) => s.archived)
|
||||
const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="ai-panel" className="ai-panel" data-testid="ai-panel">
|
||||
@@ -186,12 +193,26 @@ export function AiPanel(): React.JSX.Element {
|
||||
onChange={(e) => void selectSession(e.target.value)}
|
||||
>
|
||||
{sessions.length === 0 && <option value="">{t('ai.noSession')}</option>}
|
||||
{sessions.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.title || t('ai.untitledSession')}
|
||||
</option>
|
||||
))}
|
||||
{activeSessions.length > 0 && (
|
||||
<optgroup label={t('ai.session.active')}>
|
||||
{activeSessions.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.title || t('ai.untitledSession')}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{archivedSessions.length > 0 && (
|
||||
<optgroup label={t('ai.session.archived')}>
|
||||
{archivedSessions.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.title || t('ai.untitledSession')}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
<AiSessionMenu bookId={bookId} session={activeSession} />
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AiSession } from '@shared/types'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||
|
||||
interface AiSessionMenuProps {
|
||||
bookId: string
|
||||
session: AiSession | null
|
||||
}
|
||||
|
||||
export function AiSessionMenu({ bookId, session }: AiSessionMenuProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const loadSessions = useAiStore((s) => s.loadSessions)
|
||||
const selectSession = useAiStore((s) => s.selectSession)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const close = (): void => {
|
||||
setOpen(false)
|
||||
setExportOpen(false)
|
||||
}
|
||||
window.addEventListener('click', close)
|
||||
return () => window.removeEventListener('click', close)
|
||||
}, [open])
|
||||
|
||||
if (!session) return null
|
||||
|
||||
const handleRename = async (): Promise<void> => {
|
||||
setOpen(false)
|
||||
const title = window.prompt(t('ai.session.renamePrompt'), session.title)
|
||||
if (!title?.trim() || title.trim() === session.title) return
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.ai.sessionUpdate(bookId, session.id, { title: title.trim() })
|
||||
)
|
||||
await loadSessions(bookId, true)
|
||||
}
|
||||
|
||||
const handleArchiveToggle = async (): Promise<void> => {
|
||||
setOpen(false)
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.ai.sessionUpdate(bookId, session.id, { archived: !session.archived })
|
||||
)
|
||||
await loadSessions(bookId, true)
|
||||
}
|
||||
|
||||
const handleExport = async (format: 'markdown' | 'txt'): Promise<void> => {
|
||||
setOpen(false)
|
||||
setExportOpen(false)
|
||||
await ipcCall(() => window.electronAPI.ai.sessionExport(bookId, session.id, format))
|
||||
}
|
||||
|
||||
const handleDelete = async (): Promise<void> => {
|
||||
setOpen(false)
|
||||
if (!window.confirm(t('ai.session.deleteConfirm', { title: session.title || t('ai.untitledSession') }))) {
|
||||
return
|
||||
}
|
||||
await ipcCall(() => window.electronAPI.ai.sessionDelete(bookId, session.id))
|
||||
await loadSessions(bookId, true)
|
||||
const next = useAiStore.getState().sessions[0]
|
||||
if (next) await selectSession(next.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-session-menu-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn ai-session-menu-btn"
|
||||
data-testid="ai-session-menu-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen((v) => !v)
|
||||
}}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{open && (
|
||||
<div className="context-menu ai-session-menu" onClick={(e) => e.stopPropagation()}>
|
||||
<button type="button" onClick={() => void handleRename()}>
|
||||
{t('ai.session.rename')}
|
||||
</button>
|
||||
<button type="button" onClick={() => void handleArchiveToggle()}>
|
||||
{session.archived ? t('ai.session.unarchive') : t('ai.session.archive')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setExportOpen((v) => !v)
|
||||
}}
|
||||
>
|
||||
{t('ai.session.export')} ▾
|
||||
</button>
|
||||
{exportOpen && (
|
||||
<div className="ai-session-export-submenu">
|
||||
<button type="button" onClick={() => void handleExport('markdown')}>
|
||||
{t('ai.session.exportMd')}
|
||||
</button>
|
||||
<button type="button" onClick={() => void handleExport('txt')}>
|
||||
{t('ai.session.exportTxt')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="danger" onClick={() => void handleDelete()}>
|
||||
{t('ai.session.delete')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user