From 4adafa193647f1cd70e6083797d0e1d5f2d37d1f Mon Sep 17 00:00:00 2001 From: kun1h Date: Wed, 8 Jul 2026 10:48:31 +0800 Subject: [PATCH] feat: ship v1.0.0 with character graph and writing analytics Add P7 Cytoscape relationship graph with setting CRUD, P9 cockpit analytics driven by writing_sessions, chapter POV selection, and full test coverage. Co-authored-by: Cursor --- README.md | 8 +- .../2026-07-08-bilin-p7-graph-p9-analytics.md | 96 +-- e2e/character-graph.spec.ts | 119 ++++ e2e/writing-analytics.spec.ts | 119 ++++ package-lock.json | 612 +++--------------- package.json | 5 +- public/locales/en/translation.json | 26 + public/locales/zh-CN/translation.json | 26 + src/main/db/repositories/chapter.repo.ts | 9 +- .../db/repositories/pomodoro-daily.repo.ts | 15 + src/main/db/repositories/writing-log.repo.ts | 13 +- .../db/repositories/writing-session.repo.ts | 83 +++ src/main/ipc/handlers/analytics.handler.ts | 21 + src/main/ipc/handlers/auto.handler.ts | 9 +- src/main/ipc/handlers/book.handler.ts | 16 +- src/main/ipc/handlers/chapter.handler.ts | 16 +- src/main/ipc/handlers/graph.handler.ts | 51 ++ src/main/ipc/handlers/interactive.handler.ts | 9 +- src/main/ipc/register.ts | 16 +- .../chapter-writing-tracker.service.ts | 19 +- src/main/services/character-graph.service.ts | 140 ++++ .../services/writing-analytics.service.ts | 106 +++ src/main/services/writing-log.service.ts | 4 + src/main/services/writing-session.service.ts | 40 ++ src/preload/index.ts | 32 +- .../components/cockpit/CockpitAnalytics.tsx | 83 +++ .../components/cockpit/CockpitModal.tsx | 13 +- .../components/cockpit/HourlyHeatmap.tsx | 23 + .../components/editor/TipTapEditor.tsx | 7 + .../components/graph/CharacterGraphModal.tsx | 123 ++++ .../components/layout/EditorLayout.tsx | 32 + .../components/setting/SettingList.tsx | 8 + .../setting/SettingRelationshipsEditor.tsx | 133 ++++ .../components/settings/SettingsPage.tsx | 2 +- src/renderer/lib/graph-cytoscape.ts | 114 ++++ src/renderer/stores/useGraphStore.ts | 34 + src/renderer/styles/layout.css | 143 ++++ src/shared/electron-api.d.ts | 25 +- src/shared/ipc-channels.ts | 4 + src/shared/types.ts | 39 ++ tests/main/character-graph.test.ts | 73 +++ tests/main/writing-analytics.test.ts | 109 ++++ tests/main/writing-log.test.ts | 10 +- tests/main/writing-session.test.ts | 79 +++ 44 files changed, 2062 insertions(+), 602 deletions(-) create mode 100644 e2e/character-graph.spec.ts create mode 100644 e2e/writing-analytics.spec.ts create mode 100644 src/main/db/repositories/writing-session.repo.ts create mode 100644 src/main/ipc/handlers/analytics.handler.ts create mode 100644 src/main/ipc/handlers/graph.handler.ts create mode 100644 src/main/services/character-graph.service.ts create mode 100644 src/main/services/writing-analytics.service.ts create mode 100644 src/main/services/writing-session.service.ts create mode 100644 src/renderer/components/cockpit/CockpitAnalytics.tsx create mode 100644 src/renderer/components/cockpit/HourlyHeatmap.tsx create mode 100644 src/renderer/components/graph/CharacterGraphModal.tsx create mode 100644 src/renderer/components/setting/SettingRelationshipsEditor.tsx create mode 100644 src/renderer/lib/graph-cytoscape.ts create mode 100644 src/renderer/stores/useGraphStore.ts create mode 100644 tests/main/character-graph.test.ts create mode 100644 tests/main/writing-analytics.test.ts create mode 100644 tests/main/writing-session.test.ts diff --git a/README.md b/README.md index 4eb56f7..b37d8e1 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # 笔临 (Bilin) -长篇创作智能协作平台 — Electron 桌面客户端 v0.9.0(P5.3 番茄钟/成就 + P6.1 知识注入历史) +长篇创作智能协作平台 — Electron 桌面客户端 v1.0.0(P7 角色关系图谱 + P9 个人写作分析) -## 功能概览(v0.9.0) +## 功能概览(v1.0.0) -- 写作日志与热力图、连更统计、状态栏日更进度(P5.2) +- 角色关系图谱:Cytoscape 可视化、设定面板关系 CRUD、三种布局(P7) +- 个人写作分析:今日字速、时段热力、POV/卷分布、近 7 日趋势(P9) +- 章节视角(POV)选择与驾驶舱内嵌分析区 - AI 章节知识自动抽取、合并审核、高置信度批量采纳(P5.2) - 知识库 AI 上下文:半自动推荐 + 勾选注入四种写作模式(P6) - 驾驶舱:全书/本卷字数、存稿缓冲、伏笔摘要、快速入口(P5) diff --git a/docs/superpowers/plans/2026-07-08-bilin-p7-graph-p9-analytics.md b/docs/superpowers/plans/2026-07-08-bilin-p7-graph-p9-analytics.md index 2655ba2..6f9a9ed 100644 --- a/docs/superpowers/plans/2026-07-08-bilin-p7-graph-p9-analytics.md +++ b/docs/superpowers/plans/2026-07-08-bilin-p7-graph-p9-analytics.md @@ -1,6 +1,6 @@ # 笔临 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 (`- [ ]`) syntax for tracking. +> **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/卷分布),驾驶舱内嵌展示。 @@ -53,7 +53,7 @@ - Modify: `src/shared/types.ts` - Modify: `src/shared/ipc-channels.ts` -- [ ] **Step 1: 扩展 types** +- [x] **Step 1: 扩展 types** ```typescript export interface CharacterRelationship { @@ -97,7 +97,7 @@ export interface WritingAnalyticsSummary { 在 `SettingEntry.properties` 使用文档约定 `relationships?: CharacterRelationship[]`(JSDoc 或 inline 注释即可)。 -- [ ] **Step 2: IPC 通道** +- [x] **Step 2: IPC 通道** ```typescript GRAPH_GET_DATA: 'graph:getData', @@ -106,7 +106,7 @@ GRAPH_DELETE_RELATIONSHIP: 'graph:deleteRelationship', ANALYTICS_SUMMARY: 'analytics:summary', ``` -- [ ] **Step 3: build 验证** +- [x] **Step 3: build 验证** ```bash npm run build @@ -121,7 +121,7 @@ npm run build - Create: `src/main/db/repositories/writing-session.repo.ts` - Create: `tests/main/writing-session.test.ts`(repo 部分) -- [ ] **Step 1: writing-log.repo migrate 扩展** +- [x] **Step 1: writing-log.repo migrate 扩展** 在 `migrate()` 的 `CREATE TABLE` 块追加: @@ -139,7 +139,7 @@ CREATE INDEX IF NOT EXISTS idx_sessions_book_date ON writing_sessions (book_id, CREATE INDEX IF NOT EXISTS idx_sessions_book_started ON writing_sessions (book_id, started_at); ``` -- [ ] **Step 2: WritingSessionRepository** +- [x] **Step 2: WritingSessionRepository** ```typescript export class WritingSessionRepository { @@ -153,7 +153,7 @@ export class WritingSessionRepository { } ``` -- [ ] **Step 3: UT repo smoke** +- [x] **Step 3: UT repo smoke** ```typescript it('UT-SESSION-03: insert and updateActivity accumulates word_delta', () => { @@ -165,7 +165,7 @@ it('UT-SESSION-03: insert and updateActivity accumulates word_delta', () => { }) ``` -- [ ] **Step 4: 运行** +- [x] **Step 4: 运行** ```bash npm run test -- tests/main/writing-session.test.ts @@ -179,7 +179,7 @@ npm run test -- tests/main/writing-session.test.ts - Create: `src/main/services/writing-session.service.ts` - Modify: `tests/main/writing-session.test.ts` -- [ ] **Step 1: 实现** +- [x] **Step 1: 实现** ```typescript const IDLE_MS = 5 * 60 * 1000 @@ -217,7 +217,7 @@ export class WritingSessionService { } ``` -- [ ] **Step 2: UT-SESSION-01 / UT-SESSION-02** +- [x] **Step 2: UT-SESSION-01 / UT-SESSION-02** 使用 `vi.useFakeTimers()`: @@ -226,7 +226,7 @@ it('UT-SESSION-01: within 5min merges into one session', () => { /* advance 2min it('UT-SESSION-02: after 6min creates two sessions', () => { /* advance 6min between calls */ }) ``` -- [ ] **Step 3: 运行** +- [x] **Step 3: 运行** ```bash npm run test -- tests/main/writing-session.test.ts @@ -243,7 +243,7 @@ npm run test -- tests/main/writing-session.test.ts - Modify: `src/shared/types.ts`(UpdateChapterParams) - Modify: `src/main/ipc/register.ts`(trackerFor 注入 sessions) -- [ ] **Step 1: chapter.repo mapRow + update** +- [x] **Step 1: chapter.repo mapRow + update** ```typescript // mapRow 增加 @@ -253,7 +253,7 @@ povCharacterId: (row.pov_character_id as string) || null, // UPDATE SQL 增加 pov_character_id = ? ``` -- [ ] **Step 2: trackerFor 签名** +- [x] **Step 2: trackerFor 签名** ```typescript export function trackerFor( @@ -279,7 +279,7 @@ supplementOnFinish(...) { } ``` -- [ ] **Step 3: register.ts** +- [x] **Step 3: register.ts** ```typescript const writingSessionRepo = new WritingSessionRepository(writingLogRepo.getDb()) @@ -287,7 +287,7 @@ const writingSessions = new WritingSessionService(writingSessionRepo) // registerChapterHandlers(books, writingLogs, writingSessions) ``` -- [ ] **Step 4: build** +- [x] **Step 4: build** ```bash npm run build @@ -301,7 +301,7 @@ npm run build - Create: `src/main/services/character-graph.service.ts` - Create: `tests/main/character-graph.test.ts` -- [ ] **Step 1: getData 边去重** +- [x] **Step 1: getData 边去重** ```typescript export class CharacterGraphService { @@ -319,7 +319,7 @@ export class CharacterGraphService { } ``` -- [ ] **Step 2: upsertRelationship / deleteRelationship** +- [x] **Step 2: upsertRelationship / deleteRelationship** ```typescript upsertRelationship(bookId, sourceId, input: Partial & { targetId: string; label: string; intimacy: number }): CharacterRelationship { @@ -331,11 +331,11 @@ upsertRelationship(bookId, sourceId, input: Partial & { t deleteRelationship(bookId, sourceId, relationshipId): void ``` -- [ ] **Step 3: UT-GRAPH-01 / UT-GRAPH-02 / UT-GRAPH-03** +- [x] **Step 3: UT-GRAPH-01 / UT-GRAPH-02 / UT-GRAPH-03** 内存 DB + migrate + 创建 3 character settings + 2 relationships。 -- [ ] **Step 4: 运行** +- [x] **Step 4: 运行** ```bash npm run test -- tests/main/character-graph.test.ts @@ -349,7 +349,7 @@ npm run test -- tests/main/character-graph.test.ts - Create: `src/main/services/writing-analytics.service.ts` - Create: `tests/main/writing-analytics.test.ts` -- [ ] **Step 1: getSummary 实现** +- [x] **Step 1: getSummary 实现** ```typescript export class WritingAnalyticsService { @@ -385,9 +385,9 @@ function computeWpm(sessions: { wordDelta: number; startedAt: string; endedAt: s } ``` -- [ ] **Step 2: UT-ANALYTICS-01 / UT-ANALYTICS-02** +- [x] **Step 2: UT-ANALYTICS-01 / UT-ANALYTICS-02** -- [ ] **Step 3: IT-ANALYTICS-01** +- [x] **Step 3: IT-ANALYTICS-01** ```typescript it('IT-ANALYTICS-01: recordDelta feeds session then getSummary', () => { @@ -398,7 +398,7 @@ it('IT-ANALYTICS-01: recordDelta feeds session then getSummary', () => { }) ``` -- [ ] **Step 4: 运行** +- [x] **Step 4: 运行** ```bash npm run test -- tests/main/writing-analytics.test.ts @@ -416,7 +416,7 @@ npm run test -- tests/main/writing-analytics.test.ts - Modify: `src/preload/index.ts` - Modify: `src/shared/electron-api.d.ts` -- [ ] **Step 1: graph.handler** +- [x] **Step 1: graph.handler** ```typescript export function registerGraphHandlers(registry: BookRegistryService): void { @@ -429,7 +429,7 @@ export function registerGraphHandlers(registry: BookRegistryService): void { } ``` -- [ ] **Step 2: analytics.handler** +- [x] **Step 2: analytics.handler** ```typescript export function registerAnalyticsHandlers( @@ -443,7 +443,7 @@ export function registerAnalyticsHandlers( } ``` -- [ ] **Step 3: register.ts 串联** +- [x] **Step 3: register.ts 串联** ```typescript const writingSessionRepo = new WritingSessionRepository(writingLogRepo.getDb()) @@ -453,7 +453,7 @@ registerAnalyticsHandlers(books, writingLogs, writingSessionRepo, pomodoroDailyR registerChapterHandlers(books, writingLogs, writingSessions) ``` -- [ ] **Step 4: preload** +- [x] **Step 4: preload** ```typescript graph: { @@ -466,7 +466,7 @@ analytics: { } ``` -- [ ] **Step 5: build** +- [x] **Step 5: build** ```bash npm run build @@ -484,14 +484,14 @@ npm run build - Modify: `src/renderer/components/layout/EditorLayout.tsx`(挂载模态) - Modify: `src/renderer/styles/layout.css` -- [ ] **Step 1: 安装依赖** +- [x] **Step 1: 安装依赖** ```bash npm install cytoscape cytoscape-cose-bilkent --legacy-peer-deps npm install -D @types/cytoscape --legacy-peer-deps ``` -- [ ] **Step 2: useGraphStore** +- [x] **Step 2: useGraphStore** ```typescript interface GraphStore { @@ -504,7 +504,7 @@ interface GraphStore { } ``` -- [ ] **Step 3: CharacterGraphModal 骨架** +- [x] **Step 3: CharacterGraphModal 骨架** ```tsx export function CharacterGraphModal(): React.JSX.Element | null { @@ -532,9 +532,9 @@ export function CharacterGraphModal(): React.JSX.Element | null { `initCytoscape` 注册 `cytoscape.use(coseBilkent)`,边颜色按 intimacy 映射 spec §6.1。 -- [ ] **Step 4: EditorLayout 挂载 ``** +- [x] **Step 4: EditorLayout 挂载 ``** -- [ ] **Step 5: build** +- [x] **Step 5: build** ```bash npm run build @@ -549,7 +549,7 @@ npm run build - Modify: `src/renderer/components/editor/TipTapEditor.tsx`(character setting 下方嵌入) - Modify: `src/renderer/components/setting/SettingList.tsx`(setting-open-graph 按钮) -- [ ] **Step 1: SettingRelationshipsEditor** +- [x] **Step 1: SettingRelationshipsEditor** 当 `target.kind === 'setting'` 且 entry.type === 'character': @@ -566,7 +566,7 @@ npm run build 保存调用 `window.electronAPI.graph.upsertRelationship`。 -- [ ] **Step 2: SettingList 工具栏** +- [x] **Step 2: SettingList 工具栏** ```tsx ``` -- [ ] **Step 3: build** +- [x] **Step 3: build** ```bash npm run build @@ -591,7 +591,7 @@ npm run build - Modify: `src/renderer/components/layout/EditorLayout.tsx`(chapter-pov-select) - Modify: `src/renderer/stores/useCockpitStore.ts`(可选:loadAnalytics) -- [ ] **Step 1: CockpitModal 并行加载** +- [x] **Step 1: CockpitModal 并行加载** ```typescript useEffect(() => { @@ -603,7 +603,7 @@ useEffect(() => { }, [open, bookId, activeVolumeId]) ``` -- [ ] **Step 2: CockpitAnalytics 组件** +- [x] **Step 2: CockpitAnalytics 组件** ```tsx
@@ -618,7 +618,7 @@ useEffect(() => { ``` -- [ ] **Step 3: chapter-pov-select** +- [x] **Step 3: chapter-pov-select** 在 `#editor-statusbar` 当 `isChapterTarget`: @@ -635,7 +635,7 @@ useEffect(() => { `updatePov` → `chapter.update(bookId, chapterId, { povCharacterId })`. -- [ ] **Step 4: build** +- [x] **Step 4: build** ```bash npm run build @@ -650,9 +650,9 @@ npm run build - Modify: `public/locales/en/translation.json` - Modify: `src/renderer/styles/layout.css` -- [ ] **Step 1: 添加 spec §7 全部 i18n 键(graph / relationship / analytics / chapter.pov / cockpit.openGraph)** +- [x] **Step 1: 添加 spec §7 全部 i18n 键(graph / relationship / analytics / chapter.pov / cockpit.openGraph)** -- [ ] **Step 2: CSS** +- [x] **Step 2: CSS** ```css .character-graph-modal .modal-content { max-width: 95vw; width: 1100px; } @@ -664,7 +664,7 @@ npm run build .pov-bar, .volume-bar { height: 8px; background: var(--accent); border-radius: 4px; } ``` -- [ ] **Step 3: build** +- [x] **Step 3: build** ```bash npm run build @@ -681,7 +681,7 @@ npm run build - Modify: `README.md` - Modify: `src/renderer/components/settings/SettingsPage.tsx` -- [ ] **Step 1: E2E-GRAPH-01~04** +- [x] **Step 1: E2E-GRAPH-01~04** ```typescript // 创建书 → 新建 character 设定 A/B → SettingRelationshipsEditor 添加关系 @@ -690,14 +690,14 @@ npm run build // → graph-layout-circle click ``` -- [ ] **Step 2: E2E-ANALYTICS-01~02** +- [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 含角色名 ``` -- [ ] **Step 3: 全量测试** +- [x] **Step 3: 全量测试** ```bash npm run build @@ -705,11 +705,11 @@ npm run test npm run test:e2e -- e2e/character-graph.spec.ts e2e/writing-analytics.spec.ts ``` -- [ ] **Step 4: 版本 1.0.0** +- [x] **Step 4: 版本 1.0.0** `package.json` / `README.md` / Settings 关于页 → `v1.0.0` -- [ ] **Step 5: 勾选本 plan 全部 checkbox** +- [x] **Step 5: 勾选本 plan 全部 checkbox** --- diff --git a/e2e/character-graph.spec.ts b/e2e/character-graph.spec.ts new file mode 100644 index 0000000..d95cc5a --- /dev/null +++ b/e2e/character-graph.spec.ts @@ -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 { + 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 { + 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 { + 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 { + 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() + }) +}) diff --git a/e2e/writing-analytics.spec.ts b/e2e/writing-analytics.spec.ts new file mode 100644 index 0000000..d7aed91 --- /dev/null +++ b/e2e/writing-analytics.spec.ts @@ -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 { + 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 { + 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 { + 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 { + 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() + }) +}) diff --git a/package-lock.json b/package-lock.json index 291b3b7..74f7c79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bilin", - "version": "0.1.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bilin", - "version": "0.1.0", + "version": "0.9.0", "license": "MIT", "dependencies": { "@radix-ui/react-checkbox": "^1.3.6", @@ -18,6 +18,8 @@ "@tiptap/extension-underline": "^3.27.1", "@tiptap/react": "^3.27.1", "@tiptap/starter-kit": "^3.27.1", + "cytoscape": "^3.34.0", + "cytoscape-cose-bilkent": "^4.1.0", "diff-match-patch": "^1.0.5", "electron": "^36.9.0", "i18next": "^24.2.3", @@ -30,6 +32,7 @@ }, "devDependencies": { "@playwright/test": "^1.51.0", + "@types/cytoscape": "^3.21.9", "@types/diff-match-patch": "^1.0.36", "@types/node": "^22.13.0", "@types/react": "^18.3.18", @@ -48,7 +51,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", @@ -63,7 +66,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -73,7 +76,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -104,7 +107,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -114,7 +117,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.7", @@ -131,7 +134,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.29.7", @@ -148,7 +151,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -158,7 +161,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -168,7 +171,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.29.7", @@ -182,7 +185,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.29.7", @@ -210,7 +213,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -220,7 +223,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -230,7 +233,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -240,7 +243,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.29.7", @@ -254,7 +257,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -327,7 +330,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -342,7 +345,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -361,7 +364,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -1198,7 +1201,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1209,7 +1212,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1220,7 +1223,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1230,14 +1233,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2958,6 +2961,13 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/cytoscape": { + "version": "3.21.9", + "resolved": "https://registry.npmmirror.com/@types/cytoscape/-/cytoscape-3.21.9.tgz", + "integrity": "sha512-JyrG4tllI6jvuISPjHK9j2Xv/LTbnLekLke5otGStjFluIyA9JjgnvgZrSBsp8cEDpiTjwgZUZwpPv8TSBcoLw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -3046,12 +3056,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -3062,6 +3074,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -3448,142 +3461,6 @@ "dev": true, "license": "ISC" }, - "node_modules/archiver": { - "version": "5.3.2", - "resolved": "https://registry.npmmirror.com/archiver/-/archiver-5.3.2.tgz", - "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "archiver-utils": "^2.1.0", - "async": "^3.2.4", - "buffer-crc32": "^0.2.1", - "readable-stream": "^3.6.0", - "readdir-glob": "^1.1.2", - "tar-stream": "^2.2.0", - "zip-stream": "^4.1.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/archiver-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-2.1.0.tgz", - "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "glob": "^7.1.4", - "graceful-fs": "^4.2.0", - "lazystream": "^1.0.0", - "lodash.defaults": "^4.2.0", - "lodash.difference": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.union": "^4.6.0", - "normalize-path": "^3.0.0", - "readable-stream": "^2.0.0" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/archiver-utils/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/archiver-utils/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/archiver-utils/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/archiver-utils/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/archiver-utils/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/archiver-utils/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/archiver-utils/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/are-we-there-yet": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", @@ -3719,7 +3596,7 @@ "version": "2.10.42", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -3782,7 +3659,7 @@ "version": "4.28.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", - "devOptional": true, + "dev": true, "funding": [ { "type": "opencollective", @@ -3987,7 +3864,7 @@ "version": "1.0.30001800", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", - "devOptional": true, + "dev": true, "funding": [ { "type": "opencollective", @@ -4235,23 +4112,6 @@ "node": ">=0.10.0" } }, - "node_modules/compress-commons": { - "version": "4.1.2", - "resolved": "https://registry.npmmirror.com/compress-commons/-/compress-commons-4.1.2.tgz", - "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "buffer-crc32": "^0.2.13", - "crc32-stream": "^4.0.2", - "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4346,7 +4206,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/core-util-is": { @@ -4354,7 +4214,17 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } }, "node_modules/crc": { "version": "3.8.0", @@ -4367,35 +4237,6 @@ "buffer": "^5.1.0" } }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/crc32-stream": { - "version": "4.0.3", - "resolved": "https://registry.npmmirror.com/crc32-stream/-/crc32-stream-4.0.3.tgz", - "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "crc-32": "^1.2.0", - "readable-stream": "^3.4.0" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4415,8 +4256,30 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, + "node_modules/cytoscape": { + "version": "3.34.0", + "resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.34.0.tgz", + "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4773,20 +4636,6 @@ "node": ">=14.0.0" } }, - "node_modules/electron-builder-squirrel-windows": { - "version": "25.1.8", - "resolved": "https://registry.npmmirror.com/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-25.1.8.tgz", - "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "app-builder-lib": "25.1.8", - "archiver": "^5.3.1", - "builder-util": "25.1.7", - "fs-extra": "^10.1.0" - } - }, "node_modules/electron-publish": { "version": "25.1.7", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz", @@ -4807,7 +4656,7 @@ "version": "1.5.387", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/electron-vite": { @@ -4989,7 +4838,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5203,14 +5052,6 @@ "node": ">= 6" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -5296,7 +5137,7 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -5868,14 +5709,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/isbinaryfile": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", @@ -5992,7 +5825,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -6025,7 +5858,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -6056,6 +5889,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, "node_modules/lazy-val": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", @@ -6063,56 +5902,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lazystream": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/lazystream/-/lazystream-1.0.1.tgz", - "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "readable-stream": "^2.0.5" - }, - "engines": { - "node": ">= 0.6.3" - } - }, - "node_modules/lazystream/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/lazystream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lazystream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/linkifyjs": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", @@ -6126,46 +5915,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.difference": { - "version": "4.5.0", - "resolved": "https://registry.npmmirror.com/lodash.difference/-/lodash.difference-4.5.0.tgz", - "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.flatten": { - "version": "4.4.0", - "resolved": "https://registry.npmmirror.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz", - "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.union": { - "version": "4.6.0", - "resolved": "https://registry.npmmirror.com/lodash.union/-/lodash.union-4.6.0.tgz", - "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -6215,7 +5964,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -6688,7 +6437,7 @@ "version": "2.0.50", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -6710,17 +6459,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/normalize-url": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", @@ -6959,7 +6697,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -7051,14 +6789,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -7418,50 +7148,6 @@ "node": ">= 6" } }, - "node_modules/readdir-glob": { - "version": "1.1.3", - "resolved": "https://registry.npmmirror.com/readdir-glob/-/readdir-glob-1.1.3.tgz", - "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "minimatch": "^5.1.0" - } - }, - "node_modules/readdir-glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -8098,24 +7784,6 @@ "node": ">=10" } }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/tar/node_modules/minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", @@ -8258,7 +7926,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -8314,7 +7982,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "devOptional": true, + "dev": true, "funding": [ { "type": "opencollective", @@ -8752,7 +8420,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/yargs": { @@ -8807,102 +8475,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zip-stream": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/zip-stream/-/zip-stream-4.1.1.tgz", - "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "archiver-utils": "^3.0.4", - "compress-commons": "^4.1.2", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/zip-stream/node_modules/archiver-utils": { - "version": "3.0.4", - "resolved": "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-3.0.4.tgz", - "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "glob": "^7.2.3", - "graceful-fs": "^4.2.0", - "lazystream": "^1.0.0", - "lodash.defaults": "^4.2.0", - "lodash.difference": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.union": "^4.6.0", - "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/zip-stream/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/zip-stream/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/zip-stream/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/zip-stream/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/zustand": { "version": "5.0.14", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", diff --git a/package.json b/package.json index 6a27a7f..0f73c69 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bilin", - "version": "0.9.0", + "version": "1.0.0", "description": "笔临 - 长篇创作智能协作平台", "main": "./out/main/index.js", "type": "module", @@ -28,6 +28,8 @@ "@tiptap/extension-underline": "^3.27.1", "@tiptap/react": "^3.27.1", "@tiptap/starter-kit": "^3.27.1", + "cytoscape": "^3.34.0", + "cytoscape-cose-bilkent": "^4.1.0", "diff-match-patch": "^1.0.5", "electron": "^36.9.0", "i18next": "^24.2.3", @@ -40,6 +42,7 @@ }, "devDependencies": { "@playwright/test": "^1.51.0", + "@types/cytoscape": "^3.21.9", "@types/diff-match-patch": "^1.0.36", "@types/node": "^22.13.0", "@types/react": "^18.3.18", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 5a8c187..c9bd06d 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -226,6 +226,32 @@ "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", diff --git a/public/locales/zh-CN/translation.json b/public/locales/zh-CN/translation.json index 0ef8570..af4e6f2 100644 --- a/public/locales/zh-CN/translation.json +++ b/public/locales/zh-CN/translation.json @@ -226,6 +226,32 @@ "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": "继续", diff --git a/src/main/db/repositories/chapter.repo.ts b/src/main/db/repositories/chapter.repo.ts index 43df0e9..a4e546c 100644 --- a/src/main/db/repositories/chapter.repo.ts +++ b/src/main/db/repositories/chapter.repo.ts @@ -13,7 +13,8 @@ function mapRow(row: Record): 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 } } @@ -66,6 +67,7 @@ export class ChapterRepository { status: ChapterStatus cursorOffset: number publishStatus: PublishStatus + povCharacterId: string | null }> ): Chapter { const existing = this.get(id) @@ -73,12 +75,14 @@ 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 = ?, updated_at = datetime('now') WHERE id = ?` ) .run( @@ -87,6 +91,7 @@ export class ChapterRepository { patch.status ?? existing.status, patch.cursorOffset ?? existing.cursorOffset, patch.publishStatus ?? existing.publishStatus ?? 'draft', + povCharacterId, wordCount, id ) diff --git a/src/main/db/repositories/pomodoro-daily.repo.ts b/src/main/db/repositories/pomodoro-daily.repo.ts index 097dbd8..1f088be 100644 --- a/src/main/db/repositories/pomodoro-daily.repo.ts +++ b/src/main/db/repositories/pomodoro-daily.repo.ts @@ -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 + } + } } diff --git a/src/main/db/repositories/writing-log.repo.ts b/src/main/db/repositories/writing-log.repo.ts index 3fd9339..82b2984 100644 --- a/src/main/db/repositories/writing-log.repo.ts +++ b/src/main/db/repositories/writing-log.repo.ts @@ -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) `) } diff --git a/src/main/db/repositories/writing-session.repo.ts b/src/main/db/repositories/writing-session.repo.ts new file mode 100644 index 0000000..425c4d7 --- /dev/null +++ b/src/main/db/repositories/writing-session.repo.ts @@ -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): 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 + | 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[] + 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[] + return rows.map(mapRow) + } +} diff --git a/src/main/ipc/handlers/analytics.handler.ts b/src/main/ipc/handlers/analytics.handler.ts new file mode 100644 index 0000000..0eb6a2c --- /dev/null +++ b/src/main/ipc/handlers/analytics.handler.ts @@ -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)) + ) +} diff --git a/src/main/ipc/handlers/auto.handler.ts b/src/main/ipc/handlers/auto.handler.ts index 49baac6..6193188 100644 --- a/src/main/ipc/handlers/auto.handler.ts +++ b/src/main/ipc/handlers/auto.handler.ts @@ -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 }) diff --git a/src/main/ipc/handlers/book.handler.ts b/src/main/ipc/handlers/book.handler.ts index 6c79d71..a03de0f 100644 --- a/src/main/ipc/handlers/book.handler.ts +++ b/src/main/ipc/handlers/book.handler.ts @@ -2,9 +2,15 @@ import { ipcMain } from 'electron' import { IPC } from '../../../shared/ipc-channels' import type { BookMeta, CreateBookParams } 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,7 +24,13 @@ 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( diff --git a/src/main/ipc/handlers/chapter.handler.ts b/src/main/ipc/handlers/chapter.handler.ts index 29eaebb..97503d7 100644 --- a/src/main/ipc/handlers/chapter.handler.ts +++ b/src/main/ipc/handlers/chapter.handler.ts @@ -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,8 @@ export function registerChapterHandlers( title, content, status, - cursorOffset + cursorOffset, + povCharacterId }: { bookId: string chapterId: string @@ -80,6 +83,7 @@ export function registerChapterHandlers( content?: string status?: ChapterStatus cursorOffset?: number + povCharacterId?: string | null } ) => wrap(() => { @@ -87,7 +91,8 @@ export function registerChapterHandlers( title, content, status, - cursorOffset + cursorOffset, + povCharacterId }) ftsSync.upsert( registry.getDb(bookId), @@ -96,7 +101,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 }) diff --git a/src/main/ipc/handlers/graph.handler.ts b/src/main/ipc/handlers/graph.handler.ts new file mode 100644 index 0000000..414499a --- /dev/null +++ b/src/main/ipc/handlers/graph.handler.ts @@ -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 & { + 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) + }) + ) +} diff --git a/src/main/ipc/handlers/interactive.handler.ts b/src/main/ipc/handlers/interactive.handler.ts index 4f33eb6..0676953 100644 --- a/src/main/ipc/handlers/interactive.handler.ts +++ b/src/main/ipc/handlers/interactive.handler.ts @@ -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() @@ -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 }) diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index 6154a01..c6c6cab 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -10,6 +10,8 @@ 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 { registerChapterHandlers } from './handlers/chapter.handler' @@ -29,6 +31,8 @@ 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 { AiClientService } from '../services/ai-client.service' import { NetworkMonitorService } from '../services/network-monitor.service' @@ -46,6 +50,8 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg 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 +67,8 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg networkMonitor = new NetworkMonitorService() registerSettingsHandlers(settings) - registerBookHandlers(books) - registerChapterHandlers(books, writingLogs) + registerBookHandlers(books, writingSessions) + registerChapterHandlers(books, writingLogs, writingSessions) registerShortcutHandlers(shortcutManager) registerOutlineHandlers(books) registerSettingHandlers(books) @@ -72,14 +78,16 @@ 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) return { settings, books, shortcuts: shortcutManager } diff --git a/src/main/services/chapter-writing-tracker.service.ts b/src/main/services/chapter-writing-tracker.service.ts index df40fea..231b2ca 100644 --- a/src/main/services/chapter-writing-tracker.service.ts +++ b/src/main/services/chapter-writing-tracker.service.ts @@ -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) } } diff --git a/src/main/services/character-graph.service.ts b/src/main/services/character-graph.service.ts new file mode 100644 index 0000000..c8847ab --- /dev/null +++ b/src/main/services/character-graph.service.ts @@ -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() + 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() + 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 & { + 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 } + }) + } +} diff --git a/src/main/services/writing-analytics.service.ts b/src/main/services/writing-analytics.service.ts new file mode 100644 index 0000000..e8ac0f3 --- /dev/null +++ b/src/main/services/writing-analytics.service.ts @@ -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() + 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() + 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 + } + } +} diff --git a/src/main/services/writing-log.service.ts b/src/main/services/writing-log.service.ts index a0df24d..496542d 100644 --- a/src/main/services/writing-log.service.ts +++ b/src/main/services/writing-log.service.ts @@ -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) diff --git a/src/main/services/writing-session.service.ts b/src/main/services/writing-session.service.ts new file mode 100644 index 0000000..5cb704b --- /dev/null +++ b/src/main/services/writing-session.service.ts @@ -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() + + 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) + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 0f1c120..f6e3238 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -50,7 +50,10 @@ import type { PomodoroState, GoalNotificationPayload, KnowledgeInjectionLog, - WritingAchievement + WritingAchievement, + CharacterGraphData, + CharacterRelationship, + WritingAnalyticsSummary } from '../shared/types' const electronAPI = { @@ -513,6 +516,33 @@ const electronAPI = { shouldShowOnOpen: (bookId: string): Promise> => ipcRenderer.invoke(IPC.COCKPIT_SHOULD_SHOW, { bookId }) }, + graph: { + getData: ( + bookId: string, + filter?: 'all' | 'connected' + ): Promise> => + ipcRenderer.invoke(IPC.GRAPH_GET_DATA, { bookId, filter }), + upsertRelationship: ( + bookId: string, + sourceId: string, + relationship: Partial & { + targetId: string + label: string + intimacy: number + } + ): Promise> => + ipcRenderer.invoke(IPC.GRAPH_UPSERT_RELATIONSHIP, { bookId, sourceId, relationship }), + deleteRelationship: ( + bookId: string, + sourceId: string, + relationshipId: string + ): Promise> => + ipcRenderer.invoke(IPC.GRAPH_DELETE_RELATIONSHIP, { bookId, sourceId, relationshipId }) + }, + analytics: { + getSummary: (bookId: string): Promise> => + ipcRenderer.invoke(IPC.ANALYTICS_SUMMARY, { bookId }) + }, bridge: { get: ( bookId: string, diff --git a/src/renderer/components/cockpit/CockpitAnalytics.tsx b/src/renderer/components/cockpit/CockpitAnalytics.tsx new file mode 100644 index 0000000..3494e0a --- /dev/null +++ b/src/renderer/components/cockpit/CockpitAnalytics.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from 'react-i18next' +import type { WritingAnalyticsSummary } from '@shared/types' +import { HourlyHeatmap } from '@renderer/components/cockpit/HourlyHeatmap' +import { useGraphStore } from '@renderer/stores/useGraphStore' + +interface CockpitAnalyticsProps { + analytics: WritingAnalyticsSummary | null +} + +export function CockpitAnalytics({ analytics }: CockpitAnalyticsProps): React.JSX.Element | null { + const { t } = useTranslation() + + if (!analytics) return null + + const speedLabel = + analytics.todaySpeedWpm != null + ? t('analytics.speedToday', { wpm: analytics.todaySpeedWpm }) + : t('analytics.speedUnknown') + + const povTop = analytics.povDistribution.slice(0, 5) + const povMax = Math.max(...povTop.map((p) => p.words), 1) + const volMax = Math.max(...analytics.volumeDistribution.map((v) => v.words), 1) + const weekMax = Math.max(...analytics.weekTrend.map((d) => d.words), 1) + + const povName = (characterId: string, name: string): string => + characterId === '__none__' ? t('analytics.povUnassigned') : name + + return ( +
+

{t('analytics.title')}

+
{speedLabel}
+
+
{t('analytics.hourlyHeatmap')}
+ +
+
+
{t('analytics.povDistribution')}
+ {povTop.map((p) => ( +
+ {povName(p.characterId, p.name)} +
+
+
+ {p.words} +
+ ))} +
+
+
{t('analytics.volumeDistribution')}
+ {analytics.volumeDistribution.map((v) => ( +
+ {v.title} +
+
+
+ {v.words} +
+ ))} +
+
+
{t('analytics.weekTrend')}
+
+ {analytics.weekTrend.map((d) => ( +
+ ))} +
+
+ +
+ ) +} diff --git a/src/renderer/components/cockpit/CockpitModal.tsx b/src/renderer/components/cockpit/CockpitModal.tsx index b2047b1..0118f12 100644 --- a/src/renderer/components/cockpit/CockpitModal.tsx +++ b/src/renderer/components/cockpit/CockpitModal.tsx @@ -1,11 +1,13 @@ -import { useEffect } from 'react' -import type { WritingMilestone } from '@shared/types' +import { useEffect, useState } from 'react' +import type { WritingMilestone, WritingAnalyticsSummary } from '@shared/types' import { useTranslation } from 'react-i18next' import { useBookStore } from '@renderer/stores/useBookStore' import { useEditStore } from '@renderer/stores/useEditStore' import { useCockpitStore } from '@renderer/stores/useCockpitStore' import { WritingHeatmap } from '@renderer/components/cockpit/WritingHeatmap' +import { CockpitAnalytics } from '@renderer/components/cockpit/CockpitAnalytics' import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore' +import { ipcCall } from '@renderer/lib/ipc-client' interface CockpitModalProps { onOpenBridge: (chapterId: string) => void @@ -28,10 +30,14 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele const switchTarget = useEditStore((s) => s.switchTarget) const { open, summary, loading, close, loadSummary } = useCockpitStore() const openForgottenFilter = useKnowledgeStore((s) => s.openForgottenFilter) + const [analytics, setAnalytics] = useState(null) useEffect(() => { if (!open || !bookId) return - void loadSummary(bookId, activeVolumeId ?? undefined) + void Promise.all([ + loadSummary(bookId, activeVolumeId ?? undefined), + ipcCall(() => window.electronAPI.analytics.getSummary(bookId)).then(setAnalytics) + ]) }, [open, bookId, activeVolumeId, loadSummary]) if (!open) return null @@ -117,6 +123,7 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele
{t('cockpit.pomodoroToday', { count: summary.pomodoroTodayCount ?? 0 })}
+
{t('cockpit.achievements')}
diff --git a/src/renderer/components/cockpit/HourlyHeatmap.tsx b/src/renderer/components/cockpit/HourlyHeatmap.tsx new file mode 100644 index 0000000..42cabd7 --- /dev/null +++ b/src/renderer/components/cockpit/HourlyHeatmap.tsx @@ -0,0 +1,23 @@ +interface HourlyHeatmapProps { + data: number[] + testId?: string +} + +export function HourlyHeatmap({ data, testId }: HourlyHeatmapProps): React.JSX.Element { + const max = Math.max(...data, 1) + return ( +
+ {data.map((value, hour) => ( +
0 ? 0.35 + (value / max) * 0.65 : 0.2, + background: value > 0 ? 'var(--accent)' : undefined + }} + /> + ))} +
+ ) +} diff --git a/src/renderer/components/editor/TipTapEditor.tsx b/src/renderer/components/editor/TipTapEditor.tsx index b3c07fd..71f6cf2 100644 --- a/src/renderer/components/editor/TipTapEditor.tsx +++ b/src/renderer/components/editor/TipTapEditor.tsx @@ -10,6 +10,7 @@ import { editorDirtyAtom, editorSavingAtom, lastSavedAtAtom, wordCountAtom } fro import { useBookStore } from '@renderer/stores/useBookStore' import { flushEditSession, registerEditorFlush, useEditStore } from '@renderer/stores/useEditStore' import { ipcCall } from '@renderer/lib/ipc-client' +import { SettingRelationshipsEditor } from '@renderer/components/setting/SettingRelationshipsEditor' import { LandmarkMark } from '@renderer/extensions/landmark-mark' import { SettingMention } from '@renderer/extensions/mention' import { registerHighlightToken, registerInsertLandmark, registerEditorInsert } from '@renderer/lib/editor-commands' @@ -347,6 +348,9 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E setMentionOpen(false) } + const settingEntry = + target?.kind === 'setting' ? settings.find((s) => s.id === target.id) : null + if (!target) { return
{t('editor.placeholder')}
} @@ -354,6 +358,9 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E return (
+ {settingEntry?.type === 'character' && bookId && ( + + )} {mentionOpen && mentionCandidates.length > 0 && (
{mentionCandidates.map((s) => ( diff --git a/src/renderer/components/graph/CharacterGraphModal.tsx b/src/renderer/components/graph/CharacterGraphModal.tsx new file mode 100644 index 0000000..1a30072 --- /dev/null +++ b/src/renderer/components/graph/CharacterGraphModal.tsx @@ -0,0 +1,123 @@ +import { useEffect, useRef, useState } from 'react' +import type cytoscape from 'cytoscape' +import { useTranslation } from 'react-i18next' +import { useBookStore } from '@renderer/stores/useBookStore' +import { useGraphStore } from '@renderer/stores/useGraphStore' +import { useEditStore } from '@renderer/stores/useEditStore' +import { initCytoscape, runLayout, type GraphSelection, INTIMACY_COLORS } from '@renderer/lib/graph-cytoscape' + +export function CharacterGraphModal(): React.JSX.Element | null { + const { t } = useTranslation() + const bookId = useBookStore((s) => s.currentBookId) + const { open, data, loading, filter, close, load, setFilter } = useGraphStore() + const switchTarget = useEditStore((s) => s.switchTarget) + const cyRef = useRef(null) + const containerRef = useRef(null) + const [selection, setSelection] = useState(null) + + useEffect(() => { + if (!open || !bookId) return + void load(bookId, filter) + }, [open, bookId, filter, load]) + + useEffect(() => { + if (!open || !data || !containerRef.current) return + cyRef.current = initCytoscape(containerRef.current, data, setSelection) + return () => { + cyRef.current?.destroy() + cyRef.current = null + setSelection(null) + } + }, [open, data]) + + if (!open) return null + + const handleLayout = (name: 'cose-bilkent' | 'grid' | 'circle'): void => { + if (cyRef.current) runLayout(cyRef.current, name) + } + + const handleEditSetting = (): void => { + if (selection?.kind === 'node' && bookId) { + close() + void switchTarget({ kind: 'setting', id: selection.node.id }) + } + } + + return ( +
+
+
+

{t('graph.title')}

+ +
+
+ + + + +
+ {data?.truncated && ( +
{t('graph.truncated', { count: 100 })}
+ )} +
+
+ +
+
+
+ ) +} diff --git a/src/renderer/components/layout/EditorLayout.tsx b/src/renderer/components/layout/EditorLayout.tsx index f069522..2fa0abf 100644 --- a/src/renderer/components/layout/EditorLayout.tsx +++ b/src/renderer/components/layout/EditorLayout.tsx @@ -23,6 +23,7 @@ import { InspirationList } from '@renderer/components/inspiration/InspirationLis import { VersionModal } from '@renderer/components/version/VersionModal' import { LandmarkModal } from '@renderer/components/landmark/LandmarkModal' import { CockpitModal } from '@renderer/components/cockpit/CockpitModal' +import { CharacterGraphModal } from '@renderer/components/graph/CharacterGraphModal' import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeModal' import { editorDirtyAtom, @@ -107,6 +108,7 @@ export function EditorLayout(): React.JSX.Element { const bridgeChapterId = useCockpitStore((s) => s.bridgeChapterId) const clearBridgeChapter = useCockpitStore((s) => s.clearBridgeChapter) const updateChapterLocal = useBookStore((s) => s.updateChapterLocal) + const settings = useBookStore((s) => s.settings) const dirty = useAtomValue(editorDirtyAtom) const saving = useAtomValue(editorSavingAtom) @@ -120,6 +122,20 @@ export function EditorLayout(): React.JSX.Element { const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId) const isChapterTarget = target?.kind === 'chapter' const chapterId = target?.kind === 'chapter' ? target.id : selectedChapterId + const currentChapter = isChapterTarget && chapterId ? chapters.find((c) => c.id === chapterId) : null + const characterSettings = settings.filter((s) => s.type === 'character') + + const handlePovChange = async (povCharacterId: string | null): Promise => { + if (!currentBookId || !chapterId) return + const updated = await ipcCall(() => + window.electronAPI.chapter.update({ + bookId: currentBookId, + chapterId, + povCharacterId + }) + ) + updateChapterLocal(updated) + } useEffect(() => { void pomodoroInit() @@ -507,6 +523,21 @@ export function EditorLayout(): React.JSX.Element { )} {isChapterTarget ? ( <> + {t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })} {t('status.volume')}: {t('editor.words', { count: wordCount.volume })} {t('status.book')}: {t('editor.words', { count: wordCount.book })} @@ -532,6 +563,7 @@ export function EditorLayout(): React.JSX.Element { setBridgeOpen(true) }} /> +
+ diff --git a/src/renderer/components/setting/SettingRelationshipsEditor.tsx b/src/renderer/components/setting/SettingRelationshipsEditor.tsx new file mode 100644 index 0000000..560a7bc --- /dev/null +++ b/src/renderer/components/setting/SettingRelationshipsEditor.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { CharacterRelationship, SettingEntry } from '@shared/types' +import { useBookStore } from '@renderer/stores/useBookStore' +import { ipcCall } from '@renderer/lib/ipc-client' + +interface SettingRelationshipsEditorProps { + entry: SettingEntry + bookId: string +} + +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' + ) +} + +export function SettingRelationshipsEditor({ + entry, + bookId +}: SettingRelationshipsEditorProps): React.JSX.Element { + const { t } = useTranslation() + const settings = useBookStore((s) => s.settings) + const updateSettingLocal = useBookStore((s) => s.updateSettingLocal) + const characters = settings.filter((s) => s.type === 'character' && s.id !== entry.id) + const relationships = parseRelationships(entry) + + const [showForm, setShowForm] = useState(false) + const [targetId, setTargetId] = useState('') + const [label, setLabel] = useState('') + const [intimacy, setIntimacy] = useState(3) + + const targetName = (id: string): string => settings.find((s) => s.id === id)?.name ?? id + + const syncLocal = (nextRels: CharacterRelationship[]): void => { + updateSettingLocal({ + ...entry, + properties: { ...entry.properties, relationships: nextRels } + }) + } + + const handleAdd = async (): Promise => { + if (!targetId || !label.trim()) return + const rel = await ipcCall(() => + window.electronAPI.graph.upsertRelationship(bookId, entry.id, { + targetId, + label: label.trim(), + intimacy + }) + ) + syncLocal([...relationships.filter((r) => r.id !== rel.id), rel]) + setShowForm(false) + setTargetId('') + setLabel('') + setIntimacy(3) + } + + const handleDelete = async (relationshipId: string): Promise => { + await ipcCall(() => + window.electronAPI.graph.deleteRelationship(bookId, entry.id, relationshipId) + ) + syncLocal(relationships.filter((r) => r.id !== relationshipId)) + } + + return ( +
+

{t('relationship.title')}

+ {relationships.map((r) => ( +
+ + {targetName(r.targetId)} · {r.label} · {r.intimacy} + + +
+ ))} + {showForm ? ( +
+ + setLabel(e.target.value)} + /> + + + +
+ ) : ( + + )} +
+ ) +} diff --git a/src/renderer/components/settings/SettingsPage.tsx b/src/renderer/components/settings/SettingsPage.tsx index 531f2cc..79ae4d5 100644 --- a/src/renderer/components/settings/SettingsPage.tsx +++ b/src/renderer/components/settings/SettingsPage.tsx @@ -228,7 +228,7 @@ export function SettingsPage(): React.JSX.Element { {section === 'shortcuts' && } {section === 'about' && (

- {t('app.name')} v0.9.0 + {t('app.name')} v1.0.0
{t('app.tagline')}

diff --git a/src/renderer/lib/graph-cytoscape.ts b/src/renderer/lib/graph-cytoscape.ts new file mode 100644 index 0000000..f00e3c9 --- /dev/null +++ b/src/renderer/lib/graph-cytoscape.ts @@ -0,0 +1,114 @@ +import cytoscape from 'cytoscape' +import coseBilkent from 'cytoscape-cose-bilkent' +import type { CharacterGraphData, CharacterGraphEdge, CharacterGraphNode } from '@shared/types' + +cytoscape.use(coseBilkent) + +const INTIMACY_COLORS: Record = { + 1: '#4ea8de', + 2: '#45b7aa', + 3: '#3fb950', + 4: '#f0883e', + 5: '#e0556a' +} + +export type GraphSelection = + | { kind: 'node'; node: CharacterGraphNode } + | { kind: 'edge'; edge: CharacterGraphEdge } + | null + +function toElements(data: CharacterGraphData): cytoscape.ElementDefinition[] { + const nodes: cytoscape.ElementDefinition[] = data.nodes.map((n) => ({ + data: { id: n.id, label: n.label } + })) + const edges: cytoscape.ElementDefinition[] = data.edges.map((e) => ({ + data: { + id: e.id, + source: e.source, + target: e.target, + label: e.label, + intimacy: e.intimacy + } + })) + return [...nodes, ...edges] +} + +export function initCytoscape( + container: HTMLElement, + data: CharacterGraphData, + onSelect: (sel: GraphSelection) => void +): cytoscape.Core { + const intimacyStyles: cytoscape.Stylesheet[] = [1, 2, 3, 4, 5].map((level) => ({ + selector: `edge[intimacy = ${level}]`, + style: { + 'line-color': INTIMACY_COLORS[level], + 'target-arrow-color': INTIMACY_COLORS[level] + } + })) + + const cy = cytoscape({ + container, + elements: toElements(data), + style: [ + { + selector: 'node', + style: { + label: 'data(label)', + 'text-valign': 'center', + 'text-halign': 'center', + 'background-color': '#6e7681', + color: '#fff', + 'font-size': 11, + width: 36, + height: 36 + } + }, + { + selector: 'edge', + style: { + label: 'data(label)', + 'curve-style': 'bezier', + 'target-arrow-shape': 'triangle', + 'line-color': '#888', + 'target-arrow-color': '#888', + width: 2, + 'font-size': 9, + color: '#666' + } + }, + ...intimacyStyles, + { + selector: ':selected', + style: { + 'border-width': 2, + 'border-color': '#58a6ff' + } + } + ], + layout: { name: 'cose-bilkent', animate: false } + }) + + cy.on('tap', 'node', (evt) => { + const id = evt.target.id() + const node = data.nodes.find((n) => n.id === id) + if (node) onSelect({ kind: 'node', node }) + }) + + cy.on('tap', 'edge', (evt) => { + const id = evt.target.id() + const edge = data.edges.find((e) => e.id === id) + if (edge) onSelect({ kind: 'edge', edge }) + }) + + cy.on('tap', (evt) => { + if (evt.target === cy) onSelect(null) + }) + + return cy +} + +export function runLayout(cy: cytoscape.Core, name: 'cose-bilkent' | 'grid' | 'circle'): void { + cy.layout({ name, animate: true }).run() +} + +export { INTIMACY_COLORS } diff --git a/src/renderer/stores/useGraphStore.ts b/src/renderer/stores/useGraphStore.ts new file mode 100644 index 0000000..ecc7592 --- /dev/null +++ b/src/renderer/stores/useGraphStore.ts @@ -0,0 +1,34 @@ +import { create } from 'zustand' +import type { CharacterGraphData } from '@shared/types' +import { ipcCall } from '@renderer/lib/ipc-client' + +interface GraphState { + open: boolean + data: CharacterGraphData | null + loading: boolean + filter: 'all' | 'connected' + openModal: () => void + close: () => void + load: (bookId: string, filter?: 'all' | 'connected') => Promise + setFilter: (filter: 'all' | 'connected') => void +} + +export const useGraphStore = create((set, get) => ({ + open: false, + data: null, + loading: false, + filter: 'all', + openModal: () => set({ open: true }), + close: () => set({ open: false, data: null }), + setFilter: (filter) => set({ filter }), + load: async (bookId, filter) => { + const f = filter ?? get().filter + set({ loading: true }) + try { + const data = await ipcCall(() => window.electronAPI.graph.getData(bookId, f)) + set({ data, filter: f }) + } finally { + set({ loading: false }) + } + } +})) diff --git a/src/renderer/styles/layout.css b/src/renderer/styles/layout.css index 66f20b2..26a062c 100644 --- a/src/renderer/styles/layout.css +++ b/src/renderer/styles/layout.css @@ -1844,3 +1844,146 @@ opacity: 0.45; cursor: not-allowed; } + +.character-graph-modal .modal-content { + max-width: 95vw; + width: 1100px; +} + +.graph-toolbar { + display: flex; + gap: 8px; + padding: 8px 16px; + border-bottom: 1px solid var(--border); +} + +.graph-body { + display: flex; + min-height: 480px; +} + +#character-graph-cy { + min-height: 480px; + flex: 1; + background: var(--bg-secondary); +} + +.graph-sidebar { + width: 260px; + padding: 12px; + border-left: 1px solid var(--border); + font-size: 13px; +} + +.graph-truncated-notice { + padding: 8px 16px; + font-size: 12px; + color: var(--text-muted); + background: var(--bg-tertiary); +} + +.setting-relationships { + margin-top: 12px; + padding: 12px; + border-top: 1px solid var(--border); +} + +.setting-relationship-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 0; + font-size: 13px; +} + +.setting-relationship-form { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +} + +.cockpit-analytics { + margin-top: 16px; + display: grid; + gap: 12px; +} + +.cockpit-analytics-label { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 4px; +} + +.hourly-heatmap { + display: flex; + gap: 2px; + height: 32px; +} + +.hourly-heatmap-cell { + flex: 1; + border-radius: 2px; + background: var(--bg-tertiary); +} + +.analytics-bar-row { + display: grid; + grid-template-columns: 80px 1fr 40px; + gap: 8px; + align-items: center; + font-size: 12px; + margin-bottom: 4px; +} + +.analytics-bar-track { + height: 8px; + background: var(--bg-tertiary); + border-radius: 4px; + overflow: hidden; +} + +.pov-bar, +.volume-bar { + height: 8px; + background: var(--accent); + border-radius: 4px; +} + +.week-trend-bars { + display: flex; + align-items: flex-end; + gap: 4px; + height: 52px; +} + +.week-trend-bar { + flex: 1; + min-width: 8px; + background: var(--accent); + border-radius: 2px 2px 0 0; +} + +.status-pov-label { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.status-pov-label select { + font-size: 11px; +} + +.graph-intimacy-bar { + height: 6px; + background: var(--bg-tertiary); + border-radius: 3px; + margin: 8px 0; + overflow: hidden; +} + +.graph-intimacy-bar span { + display: block; + height: 100%; + border-radius: 3px; +} diff --git a/src/shared/electron-api.d.ts b/src/shared/electron-api.d.ts index 5fbe9f1..a578e04 100644 --- a/src/shared/electron-api.d.ts +++ b/src/shared/electron-api.d.ts @@ -49,7 +49,10 @@ import type { GoalNotificationPayload, KnowledgeInjectionLog, WritingAchievement, - PomodoroDuration + PomodoroDuration, + CharacterGraphData, + CharacterRelationship, + WritingAnalyticsSummary } from './types' export interface ElectronAPI { @@ -387,6 +390,26 @@ export interface ElectronAPI { markSeen: (bookId: string) => Promise> shouldShowOnOpen: (bookId: string) => Promise> } + graph: { + getData: (bookId: string, filter?: 'all' | 'connected') => Promise> + upsertRelationship: ( + bookId: string, + sourceId: string, + relationship: Partial & { + targetId: string + label: string + intimacy: number + } + ) => Promise> + deleteRelationship: ( + bookId: string, + sourceId: string, + relationshipId: string + ) => Promise> + } + analytics: { + getSummary: (bookId: string) => Promise> + } bridge: { get: ( bookId: string, diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 7a6bd85..534c3d4 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -126,6 +126,10 @@ export const IPC = { ACHIEVEMENT_LIST: 'achievement:list', KNOWLEDGE_LIST_INJECTIONS: 'knowledge:listInjections', GOAL_NOTIFICATION: 'goal:notification', + GRAPH_GET_DATA: 'graph:getData', + GRAPH_UPSERT_RELATIONSHIP: 'graph:upsertRelationship', + GRAPH_DELETE_RELATIONSHIP: 'graph:deleteRelationship', + ANALYTICS_SUMMARY: 'analytics:summary', COCKPIT_SUMMARY: 'cockpit:summary', COCKPIT_MARK_SEEN: 'cockpit:markSeen', COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen', diff --git a/src/shared/types.ts b/src/shared/types.ts index c7b4956..29d868c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -160,6 +160,44 @@ export interface GoalNotificationPayload { messageParams?: Record } +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 }[] +} + export interface ChapterBridgeResult { previousChapterId: string | null previousTail: string @@ -340,6 +378,7 @@ export interface UpdateChapterParams { content?: string status?: ChapterStatus cursorOffset?: number + povCharacterId?: string | null } export type AiBackend = 'lmstudio' | 'openai' | 'anthropic' diff --git a/tests/main/character-graph.test.ts b/tests/main/character-graph.test.ts new file mode 100644 index 0000000..2fbe5c8 --- /dev/null +++ b/tests/main/character-graph.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { randomUUID } from 'crypto' +import { BookRegistryService } from '../../src/main/services/book-registry' +import { CharacterGraphService } from '../../src/main/services/character-graph.service' +import { SettingRepository } from '../../src/main/db/repositories/setting.repo' +import { closeAllBookDbs } from '../../src/main/db/connection' + +describe('CharacterGraphService', () => { + let dir: string + let registry: BookRegistryService + let bookId: string + let charA: string + let charB: string + let charC: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'bilin-graph-')) + registry = new BookRegistryService(dir) + const meta = registry.create({ name: '图谱书', category: '测试', createSampleChapter: false }) + bookId = meta.id + const repo = new SettingRepository(registry.getDb(bookId)) + charA = repo.create('character', '角色A').id + charB = repo.create('character', '角色B').id + charC = repo.create('character', '角色C').id + }) + + afterEach(() => { + closeAllBookDbs() + rmSync(dir, { recursive: true, force: true }) + }) + + it('UT-GRAPH-01: getData returns nodes and edges', () => { + const svc = new CharacterGraphService(registry) + svc.upsertRelationship(bookId, charA, { targetId: charB, label: '朋友', intimacy: 3 }) + const data = svc.getData(bookId) + expect(data.nodes.length).toBe(3) + expect(data.edges).toHaveLength(1) + expect(data.edges[0].label).toBe('朋友') + }) + + it('UT-GRAPH-02: bidirectional edges dedupe to max intimacy', () => { + const svc = new CharacterGraphService(registry) + svc.upsertRelationship(bookId, charA, { targetId: charB, label: '同事', intimacy: 2 }) + svc.upsertRelationship(bookId, charB, { targetId: charA, label: '挚友', intimacy: 4 }) + const data = svc.getData(bookId) + expect(data.edges).toHaveLength(1) + expect(data.edges[0].intimacy).toBe(4) + }) + + it('UT-GRAPH-03: connected filter hides isolated nodes', () => { + const svc = new CharacterGraphService(registry) + svc.upsertRelationship(bookId, charA, { targetId: charB, label: '师徒', intimacy: 5 }) + const data = svc.getData(bookId, 'connected') + expect(data.nodes).toHaveLength(2) + expect(data.nodes.some((n) => n.id === charC)).toBe(false) + }) + + it('IT-GRAPH-01: upsert and delete relationship', () => { + const svc = new CharacterGraphService(registry) + const rel = svc.upsertRelationship(bookId, charA, { + id: randomUUID(), + targetId: charB, + label: '恋人', + intimacy: 5 + }) + expect(svc.getData(bookId).edges).toHaveLength(1) + svc.deleteRelationship(bookId, charA, rel.id) + expect(svc.getData(bookId).edges).toHaveLength(0) + }) +}) diff --git a/tests/main/writing-analytics.test.ts b/tests/main/writing-analytics.test.ts new file mode 100644 index 0000000..8b2cf08 --- /dev/null +++ b/tests/main/writing-analytics.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { BookRegistryService } from '../../src/main/services/book-registry' +import { GlobalSettingsService } from '../../src/main/services/global-settings' +import { WritingLogRepository } from '../../src/main/db/repositories/writing-log.repo' +import { WritingLogService } from '../../src/main/services/writing-log.service' +import { WritingSessionRepository } from '../../src/main/db/repositories/writing-session.repo' +import { WritingSessionService } from '../../src/main/services/writing-session.service' +import { PomodoroDailyRepository } from '../../src/main/db/repositories/pomodoro-daily.repo' +import { WritingAnalyticsService, POV_NONE } from '../../src/main/services/writing-analytics.service' +import { trackerFor } from '../../src/main/services/chapter-writing-tracker.service' +import { SettingRepository } from '../../src/main/db/repositories/setting.repo' +import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo' +import { VolumeRepository } from '../../src/main/db/repositories/volume.repo' +import { closeAllBookDbs } from '../../src/main/db/connection' + +describe('WritingAnalyticsService', () => { + let booksDir: string + let userDir: string + let settingsDir: string + let registry: BookRegistryService + let writingLogs: WritingLogService + let sessionRepo: WritingSessionRepository + let sessions: WritingSessionService + let pomodoroDaily: PomodoroDailyRepository + let analytics: WritingAnalyticsService + let bookId: string + let logRepo: WritingLogRepository + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-08T14:30:00.000Z')) + booksDir = mkdtempSync(join(tmpdir(), 'bilin-analytics-books-')) + userDir = mkdtempSync(join(tmpdir(), 'bilin-analytics-logs-')) + settingsDir = mkdtempSync(join(tmpdir(), 'bilin-analytics-settings-')) + registry = new BookRegistryService(booksDir) + const settings = new GlobalSettingsService(settingsDir) + settings.update({ ...settings.get(), dailyWordGoal: 500 }) + logRepo = new WritingLogRepository(userDir) + writingLogs = new WritingLogService(logRepo, settings) + sessionRepo = new WritingSessionRepository(logRepo.getDb()) + sessions = new WritingSessionService(sessionRepo) + pomodoroDaily = new PomodoroDailyRepository(logRepo.getDb()) + analytics = new WritingAnalyticsService(writingLogs, sessionRepo, pomodoroDaily, registry) + bookId = registry.create({ name: '分析书', category: '测试', createSampleChapter: false }).id + }) + + afterEach(() => { + vi.useRealTimers() + logRepo?.close() + closeAllBookDbs() + rmSync(booksDir, { recursive: true, force: true }) + rmSync(userDir, { recursive: true, force: true }) + rmSync(settingsDir, { recursive: true, force: true }) + }) + + it('UT-ANALYTICS-01: getSummary includes today speed from sessions', () => { + const db = registry.getDb(bookId) + const volId = new VolumeRepository(db).create('卷一', 0).id + const chapterRepo = new ChapterRepository(db) + let chapter = chapterRepo.create(volId, '第一章', 0, '

测试内容

') + const tracker = trackerFor(registry, bookId, writingLogs, sessions) + tracker.recordDelta(chapter.id, chapter.wordCount) + vi.advanceTimersByTime(2 * 60 * 1000) + chapter = chapterRepo.update(chapter.id, { content: '

测试内容扩展更多汉字

' }) + tracker.recordDelta(chapter.id, chapter.wordCount) + const summary = analytics.getSummary(bookId) + expect(summary.todayWords).toBeGreaterThan(0) + expect(summary.todaySpeedWpm).not.toBeNull() + expect(summary.todaySpeedWpm!).toBeGreaterThan(0) + expect(summary.hourlyHeatmap.some((v) => v > 0)).toBe(true) + }) + + it('UT-ANALYTICS-02: povDistribution groups by povCharacterId', () => { + const db = registry.getDb(bookId) + const settingRepo = new SettingRepository(db) + const hero = settingRepo.create('character', '主角').id + const volId = new VolumeRepository(db).create('卷一', 0).id + const ch1 = new ChapterRepository(db).create(volId, 'A', 0, '

一二三四五

') + const ch2 = new ChapterRepository(db).create(volId, 'B', 1, '

六七八九十

') + new ChapterRepository(db).update(ch1.id, { povCharacterId: hero }) + const summary = analytics.getSummary(bookId) + const heroRow = summary.povDistribution.find((p) => p.characterId === hero) + const noneRow = summary.povDistribution.find((p) => p.characterId === POV_NONE) + expect(heroRow!.words).toBeGreaterThan(0) + expect(noneRow!.words).toBeGreaterThan(0) + expect(ch2.wordCount).toBeGreaterThan(0) + }) + + it('IT-ANALYTICS-01: recordDelta feeds session then getSummary', () => { + const db = registry.getDb(bookId) + const volId = new VolumeRepository(db).create('卷一', 0).id + const chapterRepo = new ChapterRepository(db) + let chapter = chapterRepo.create(volId, '章', 0, '

一二三四五

') + const tracker = trackerFor(registry, bookId, writingLogs, sessions) + tracker.recordDelta(chapter.id, chapter.wordCount) + vi.advanceTimersByTime(2 * 60 * 1000) + chapter = chapterRepo.update(chapter.id, { content: '

一二三四五六七八九十

' }) + tracker.recordDelta(chapter.id, chapter.wordCount) + const rows = sessionRepo.listForBookDate(bookId, '2026-07-08') + expect(rows).toHaveLength(1) + expect(rows[0].wordDelta).toBeGreaterThan(0) + const summary = analytics.getSummary(bookId) + expect(summary.todaySpeedWpm).not.toBeNull() + expect(summary.todaySpeedWpm!).toBeGreaterThan(0) + }) +}) diff --git a/tests/main/writing-log.test.ts b/tests/main/writing-log.test.ts index d28ae85..61e6858 100644 --- a/tests/main/writing-log.test.ts +++ b/tests/main/writing-log.test.ts @@ -9,6 +9,8 @@ import { VolumeRepository } from '../../src/main/db/repositories/volume.repo' import { WritingLogRepository } from '../../src/main/db/repositories/writing-log.repo' import { GlobalSettingsService } from '../../src/main/services/global-settings' import { WritingLogService } from '../../src/main/services/writing-log.service' +import { WritingSessionRepository } from '../../src/main/db/repositories/writing-session.repo' +import { WritingSessionService } from '../../src/main/services/writing-session.service' import { ChapterWritingTracker } from '../../src/main/services/chapter-writing-tracker.service' import { addDays, localDateString } from '../../src/main/services/writing-date.util' import type { SqliteDb } from '../../src/main/db/types' @@ -84,10 +86,12 @@ describe('ChapterWritingTracker', () => { settings = new GlobalSettingsService(settingsDir) repo = new WritingLogRepository(userDir) writingLogs = new WritingLogService(repo, settings) + const sessionRepo = new WritingSessionRepository(repo.getDb()) + const sessions = new WritingSessionService(sessionRepo) const bookId = 'book-1' const volId = new VolumeRepository(db).create('卷一', 0).id const chapter = new ChapterRepository(db).create(volId, '第一章', 0, '

一二三四五六七八九十

') - const tracker = new ChapterWritingTracker(db, bookId, writingLogs) + const tracker = new ChapterWritingTracker(db, bookId, writingLogs, sessions) tracker.recordDelta(chapter.id, 5) tracker.supplementOnFinish(chapter.id, chapter.wordCount) expect(writingLogs.getStats(bookId).todayWords).toBe(chapter.wordCount) @@ -101,11 +105,13 @@ describe('ChapterWritingTracker', () => { settings = new GlobalSettingsService(settingsDir) repo = new WritingLogRepository(userDir) writingLogs = new WritingLogService(repo, settings) + const sessionRepo = new WritingSessionRepository(repo.getDb()) + const sessions = new WritingSessionService(sessionRepo) const bookId = 'book-1' const volId = new VolumeRepository(db).create('卷一', 0).id const chapterRepo = new ChapterRepository(db) const ch = chapterRepo.create(volId, 'A', 0, '

ab

') - const tracker = new ChapterWritingTracker(db, bookId, writingLogs) + const tracker = new ChapterWritingTracker(db, bookId, writingLogs, sessions) const updated = chapterRepo.update(ch.id, { content: '

abcd

' }) tracker.recordDelta(ch.id, updated.wordCount) const updated2 = chapterRepo.update(ch.id, { content: '

abcdef

' }) diff --git a/tests/main/writing-session.test.ts b/tests/main/writing-session.test.ts new file mode 100644 index 0000000..5a24da7 --- /dev/null +++ b/tests/main/writing-session.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest' +import { randomUUID } from 'crypto' +import { DatabaseSync } from 'node:sqlite' +import { mkdtempSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { WritingLogRepository } from '../../src/main/db/repositories/writing-log.repo' +import { WritingSessionRepository } from '../../src/main/db/repositories/writing-session.repo' +import { WritingSessionService } from '../../src/main/services/writing-session.service' + +describe('WritingSessionRepository', () => { + let userDir: string + let logRepo: WritingLogRepository + let repo: WritingSessionRepository + + afterEach(() => { + logRepo?.close() + if (userDir) rmSync(userDir, { recursive: true, force: true }) + }) + + it('UT-SESSION-03: insert and updateActivity accumulates word_delta', () => { + userDir = mkdtempSync(join(tmpdir(), 'bilin-session-')) + logRepo = new WritingLogRepository(userDir) + repo = new WritingSessionRepository(logRepo.getDb()) + const id = randomUUID() + repo.insert({ + id, + bookId: 'b1', + chapterId: 'c1', + startedAt: '2026-07-08T10:00:00.000Z', + endedAt: '2026-07-08T10:05:00.000Z', + wordDelta: 50, + date: '2026-07-08' + }) + repo.updateActivity(id, '2026-07-08T10:10:00.000Z', 30) + expect(repo.getById(id)!.wordDelta).toBe(80) + }) +}) + +describe('WritingSessionService', () => { + let userDir: string + let logRepo: WritingLogRepository + let repo: WritingSessionRepository + let service: WritingSessionService + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-08T10:00:00.000Z')) + userDir = mkdtempSync(join(tmpdir(), 'bilin-session-svc-')) + logRepo = new WritingLogRepository(userDir) + repo = new WritingSessionRepository(logRepo.getDb()) + service = new WritingSessionService(repo) + }) + + afterEach(() => { + vi.useRealTimers() + logRepo?.close() + if (userDir) rmSync(userDir, { recursive: true, force: true }) + }) + + it('UT-SESSION-01: within 5min merges into one session', () => { + service.recordActivity('b1', 'c1', 50) + vi.advanceTimersByTime(2 * 60 * 1000) + service.recordActivity('b1', 'c1', 30) + const rows = repo.listForBookDate('b1', '2026-07-08') + expect(rows).toHaveLength(1) + expect(rows[0].wordDelta).toBe(80) + }) + + it('UT-SESSION-02: after 6min creates two sessions', () => { + service.recordActivity('b1', 'c1', 50) + vi.advanceTimersByTime(6 * 60 * 1000) + service.recordActivity('b1', 'c1', 30) + const rows = repo.listForBookDate('b1', '2026-07-08') + expect(rows).toHaveLength(2) + expect(rows[0].wordDelta).toBe(50) + expect(rows[1].wordDelta).toBe(30) + }) +})