feat: ship v1.0.0 with character graph and writing analytics
Add P7 Cytoscape relationship graph with setting CRUD, P9 cockpit analytics driven by writing_sessions, chapter POV selection, and full test coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<CharacterRelationship> & { targetId: string; label: string; intimacy: number }): CharacterRelationship {
|
||||
@@ -331,11 +331,11 @@ upsertRelationship(bookId, sourceId, input: Partial<CharacterRelationship> & { 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 挂载 `<CharacterGraphModal />`**
|
||||
- [x] **Step 4: EditorLayout 挂载 `<CharacterGraphModal />`**
|
||||
|
||||
- [ ] **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
|
||||
<button data-testid="setting-open-graph" onClick={() => useGraphStore.getState().openModal()}>
|
||||
@@ -574,7 +574,7 @@ npm run build
|
||||
</button>
|
||||
```
|
||||
|
||||
- [ ] **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
|
||||
<div className="cockpit-analytics" data-testid="cockpit-analytics">
|
||||
@@ -618,7 +618,7 @@ useEffect(() => {
|
||||
</button>
|
||||
```
|
||||
|
||||
- [ ] **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**
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import path from 'path'
|
||||
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { test, expect, _electron as electron, type Page } from '@playwright/test'
|
||||
|
||||
const ROOT = path.join(import.meta.dirname, '..')
|
||||
|
||||
async function launchApp(userDataDir: string) {
|
||||
return electron.launch({
|
||||
args: [ROOT],
|
||||
env: {
|
||||
...process.env,
|
||||
BILIN_E2E: '1',
|
||||
BILIN_E2E_USER_DATA: userDataDir
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function skipOnboarding(page: Page): Promise<void> {
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
|
||||
await expect(page.getByText('欢迎使用笔临')).toBeVisible({ timeout: 15_000 })
|
||||
await page.getByRole('button', { name: '跳过' }).click()
|
||||
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
async function dismissCockpitIfOpen(page: Page): Promise<void> {
|
||||
const cockpit = page.locator('[data-testid="cockpit-modal"]')
|
||||
if (await cockpit.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await page.locator('[data-testid="cockpit-modal"] .modal-close').click()
|
||||
await expect(cockpit).toBeHidden({ timeout: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function createAndOpenBook(page: Page, name: string): Promise<void> {
|
||||
await page.getByRole('button', { name: /新建书籍/ }).first().click()
|
||||
await page.locator('.dialog-content input').first().fill(name)
|
||||
await page.getByRole('button', { name: '创建' }).click()
|
||||
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
|
||||
await dismissCockpitIfOpen(page)
|
||||
}
|
||||
|
||||
async function createCharacterSetting(page: Page, name: string): Promise<void> {
|
||||
await page.getByTestId('sidebar-tab-setting').click()
|
||||
await page.getByTestId('setting-new').click()
|
||||
await page.getByTestId('setting-name-input').fill(name)
|
||||
await page.locator('.dialog-content select').selectOption('character')
|
||||
await page.getByRole('button', { name: '创建' }).click()
|
||||
await expect(page.getByTestId(/setting-item-/)).toContainText(name, { timeout: 10_000 })
|
||||
}
|
||||
|
||||
test.describe('Character graph P7', () => {
|
||||
let userDataDir: string
|
||||
|
||||
test.beforeEach(() => {
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-graph-'))
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (userDataDir && existsSync(userDataDir)) {
|
||||
try {
|
||||
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('E2E-GRAPH-01: relationship editor and graph modal', async () => {
|
||||
const app = await launchApp(userDataDir)
|
||||
const page = await app.firstWindow()
|
||||
await skipOnboarding(page)
|
||||
await createAndOpenBook(page, '图谱测试书')
|
||||
await createCharacterSetting(page, '角色甲')
|
||||
await createCharacterSetting(page, '角色乙')
|
||||
|
||||
await page.getByText('角色甲').click()
|
||||
await expect(page.getByTestId('setting-relationships')).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByTestId('setting-relationship-add').click()
|
||||
await page.locator('.setting-relationship-form select').selectOption({ label: '角色乙' })
|
||||
await page.locator('.setting-relationship-form input[type="text"]').fill('挚友')
|
||||
await page.locator('.setting-relationship-form .btn-primary').click()
|
||||
await expect(page.getByTestId('setting-relationships')).toContainText('挚友')
|
||||
|
||||
await page.getByTestId('setting-open-graph').click()
|
||||
await expect(page.getByTestId('character-graph-cy')).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByTestId('graph-layout-circle').click()
|
||||
await expect(page.getByTestId('character-graph-sidebar')).toBeVisible()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
test('E2E-GRAPH-02: tap node shows sidebar with character name', async () => {
|
||||
const app = await launchApp(userDataDir)
|
||||
const page = await app.firstWindow()
|
||||
await skipOnboarding(page)
|
||||
await createAndOpenBook(page, '图谱节点书')
|
||||
await createCharacterSetting(page, '节点A')
|
||||
await createCharacterSetting(page, '节点B')
|
||||
|
||||
await page.getByText('节点A').click()
|
||||
await page.getByTestId('setting-relationship-add').click()
|
||||
await page.locator('.setting-relationship-form select').selectOption({ label: '节点B' })
|
||||
await page.locator('.setting-relationship-form input[type="text"]').fill('同门')
|
||||
await page.locator('.setting-relationship-form .btn-primary').click()
|
||||
|
||||
await page.getByTestId('setting-open-graph').click()
|
||||
await expect(page.getByTestId('character-graph-cy')).toBeVisible({ timeout: 10_000 })
|
||||
const cyBox = await page.getByTestId('character-graph-cy').boundingBox()
|
||||
expect(cyBox).toBeTruthy()
|
||||
await page.getByTestId('character-graph-cy').click({
|
||||
position: { x: (cyBox!.width ?? 200) / 2, y: (cyBox!.height ?? 200) / 2 }
|
||||
})
|
||||
await expect(page.getByTestId('character-graph-sidebar')).toContainText(/节点/, {
|
||||
timeout: 5000
|
||||
})
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import path from 'path'
|
||||
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { test, expect, _electron as electron, type Page } from '@playwright/test'
|
||||
|
||||
const ROOT = path.join(import.meta.dirname, '..')
|
||||
|
||||
async function launchApp(userDataDir: string) {
|
||||
return electron.launch({
|
||||
args: [ROOT],
|
||||
env: {
|
||||
...process.env,
|
||||
BILIN_E2E: '1',
|
||||
BILIN_E2E_USER_DATA: userDataDir
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function skipOnboarding(page: Page): Promise<void> {
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
|
||||
if (await page.getByText('欢迎使用笔临').isVisible()) {
|
||||
await page.getByRole('button', { name: '跳过' }).click()
|
||||
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function dismissCockpitIfOpen(page: Page): Promise<void> {
|
||||
const cockpit = page.locator('[data-testid="cockpit-modal"]')
|
||||
if (await cockpit.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await page.locator('[data-testid="cockpit-modal"] .modal-close').click()
|
||||
await expect(cockpit).toBeHidden({ timeout: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function createAndOpenBook(page: Page, name: string): Promise<void> {
|
||||
await page.getByRole('button', { name: /新建书籍/ }).first().click()
|
||||
await page.locator('.dialog-content input').first().fill(name)
|
||||
await page.getByRole('button', { name: '创建' }).click()
|
||||
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
|
||||
await dismissCockpitIfOpen(page)
|
||||
}
|
||||
|
||||
async function ensureChapterEditor(page: Page): Promise<void> {
|
||||
const items = page.locator('.chapter-item')
|
||||
if ((await items.count()) === 0) {
|
||||
await page.getByRole('button', { name: '+ 新章' }).click()
|
||||
} else {
|
||||
await items.first().click()
|
||||
}
|
||||
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
test.describe('Writing analytics P9', () => {
|
||||
let userDataDir: string
|
||||
|
||||
test.beforeEach(() => {
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-analytics-'))
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (userDataDir && existsSync(userDataDir)) {
|
||||
try {
|
||||
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('E2E-ANALYTICS-01: writing updates cockpit speed card', async () => {
|
||||
const app = await launchApp(userDataDir)
|
||||
const page = await app.firstWindow()
|
||||
await skipOnboarding(page)
|
||||
await createAndOpenBook(page, '分析测试书')
|
||||
await ensureChapterEditor(page)
|
||||
|
||||
const editor = page.locator('.ProseMirror')
|
||||
await editor.click()
|
||||
await editor.pressSequentially(
|
||||
'这是一段用于测试写作分析速度的示例文字,需要足够长度才能写入写作会话并计算今日字速。继续添加一些汉字以确保字数统计生效。'
|
||||
)
|
||||
await page.waitForTimeout(1500)
|
||||
|
||||
await page.locator('[data-testid="topbar-cockpit"]').click()
|
||||
await expect(page.locator('[data-testid="cockpit-modal"]')).toBeVisible()
|
||||
await expect(page.locator('[data-testid="cockpit-analytics"]')).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator('[data-testid="cockpit-speed-today"]')).not.toHaveText('', {
|
||||
timeout: 10_000
|
||||
})
|
||||
await app.close()
|
||||
})
|
||||
|
||||
test('E2E-ANALYTICS-02: chapter POV appears in cockpit chart', async () => {
|
||||
const app = await launchApp(userDataDir)
|
||||
const page = await app.firstWindow()
|
||||
await skipOnboarding(page)
|
||||
await createAndOpenBook(page, '视角分析书')
|
||||
await ensureChapterEditor(page)
|
||||
|
||||
await page.getByTestId('sidebar-tab-setting').click()
|
||||
await page.getByTestId('setting-new').click()
|
||||
await page.getByTestId('setting-name-input').fill('主角视角')
|
||||
await page.locator('.dialog-content select').selectOption('character')
|
||||
await page.getByRole('button', { name: '创建' }).click()
|
||||
await expect(page.getByText('主角视角')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await page.getByTestId('sidebar-tab-chapter').click()
|
||||
await ensureChapterEditor(page)
|
||||
await page.getByTestId('chapter-pov-select').selectOption({ label: '主角视角' })
|
||||
|
||||
await page.locator('[data-testid="topbar-cockpit"]').click()
|
||||
await expect(page.locator('[data-testid="cockpit-pov-chart"]')).toContainText('主角视角', {
|
||||
timeout: 10_000
|
||||
})
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
Generated
+92
-520
@@ -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",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "继续",
|
||||
|
||||
@@ -13,7 +13,8 @@ function mapRow(row: Record<string, unknown>): Chapter {
|
||||
sortOrder: row.sort_order as number,
|
||||
cursorOffset: (row.cursor_offset as number) ?? 0,
|
||||
origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin,
|
||||
publishStatus: ((row.publish_status as PublishStatus) ?? 'draft') as PublishStatus
|
||||
publishStatus: ((row.publish_status as PublishStatus) ?? 'draft') as PublishStatus,
|
||||
povCharacterId: (row.pov_character_id as string) || null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,18 @@ export class WritingLogRepository {
|
||||
completed_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_words INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (date, book_id)
|
||||
)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS writing_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
book_id TEXT NOT NULL,
|
||||
chapter_id TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT NOT NULL,
|
||||
word_delta INTEGER NOT NULL DEFAULT 0,
|
||||
date TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_book_date ON writing_sessions (book_id, date);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_book_started ON writing_sessions (book_id, started_at)
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
export interface WritingSessionRow {
|
||||
id: string
|
||||
bookId: string
|
||||
chapterId?: string
|
||||
startedAt: string
|
||||
endedAt: string
|
||||
wordDelta: number
|
||||
date: string
|
||||
}
|
||||
|
||||
function mapRow(row: Record<string, unknown>): WritingSessionRow {
|
||||
return {
|
||||
id: row.id as string,
|
||||
bookId: row.book_id as string,
|
||||
chapterId: (row.chapter_id as string) || undefined,
|
||||
startedAt: row.started_at as string,
|
||||
endedAt: row.ended_at as string,
|
||||
wordDelta: row.word_delta as number,
|
||||
date: row.date as string
|
||||
}
|
||||
}
|
||||
|
||||
export class WritingSessionRepository {
|
||||
constructor(private db: DatabaseSync) {}
|
||||
|
||||
insert(row: {
|
||||
id: string
|
||||
bookId: string
|
||||
chapterId?: string
|
||||
startedAt: string
|
||||
endedAt: string
|
||||
wordDelta: number
|
||||
date: string
|
||||
}): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO writing_sessions (id, book_id, chapter_id, started_at, ended_at, word_delta, date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
row.id,
|
||||
row.bookId,
|
||||
row.chapterId ?? null,
|
||||
row.startedAt,
|
||||
row.endedAt,
|
||||
row.wordDelta,
|
||||
row.date
|
||||
)
|
||||
}
|
||||
|
||||
updateActivity(id: string, endedAt: string, wordDelta: number): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE writing_sessions SET ended_at = ?, word_delta = word_delta + ? WHERE id = ?`
|
||||
)
|
||||
.run(endedAt, wordDelta, id)
|
||||
}
|
||||
|
||||
getById(id: string): WritingSessionRow | null {
|
||||
const row = this.db.prepare('SELECT * FROM writing_sessions WHERE id = ?').get(id) as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
return row ? mapRow(row) : null
|
||||
}
|
||||
|
||||
listForBookDate(bookId: string, date: string): WritingSessionRow[] {
|
||||
const rows = this.db
|
||||
.prepare('SELECT * FROM writing_sessions WHERE book_id = ? AND date = ? ORDER BY started_at')
|
||||
.all(bookId, date) as Record<string, unknown>[]
|
||||
return rows.map(mapRow)
|
||||
}
|
||||
|
||||
listSince(bookId: string, sinceIso: string): WritingSessionRow[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM writing_sessions WHERE book_id = ? AND started_at >= ? ORDER BY started_at`
|
||||
)
|
||||
.all(bookId, sinceIso) as Record<string, unknown>[]
|
||||
return rows.map(mapRow)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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))
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import type { CharacterRelationship } from '../../../shared/types'
|
||||
import { CharacterGraphService } from '../../services/character-graph.service'
|
||||
import type { BookRegistryService } from '../../services/book-registry'
|
||||
import { wrap } from '../result'
|
||||
|
||||
export function registerGraphHandlers(registry: BookRegistryService): void {
|
||||
const svc = () => new CharacterGraphService(registry)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.GRAPH_GET_DATA,
|
||||
(_e, { bookId, filter }: { bookId: string; filter?: 'all' | 'connected' }) =>
|
||||
wrap(() => svc().getData(bookId, filter ?? 'all'))
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.GRAPH_UPSERT_RELATIONSHIP,
|
||||
(
|
||||
_e,
|
||||
{
|
||||
bookId,
|
||||
sourceId,
|
||||
relationship
|
||||
}: {
|
||||
bookId: string
|
||||
sourceId: string
|
||||
relationship: Partial<CharacterRelationship> & {
|
||||
targetId: string
|
||||
label: string
|
||||
intimacy: number
|
||||
}
|
||||
}
|
||||
) => wrap(() => svc().upsertRelationship(bookId, sourceId, relationship))
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.GRAPH_DELETE_RELATIONSHIP,
|
||||
(
|
||||
_e,
|
||||
{
|
||||
bookId,
|
||||
sourceId,
|
||||
relationshipId
|
||||
}: { bookId: string; sourceId: string; relationshipId: string }
|
||||
) =>
|
||||
wrap(() => {
|
||||
svc().deleteRelationship(bookId, sourceId, relationshipId)
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { scheduleChapterExtraction } from '../../services/knowledge-extraction-r
|
||||
import { trackerFor } from '../../services/chapter-writing-tracker.service'
|
||||
import { recordKnowledgeInjection } from '../../services/knowledge-injection-runner'
|
||||
import type { WritingLogService } from '../../services/writing-log.service'
|
||||
import type { WritingSessionService } from '../../services/writing-session.service'
|
||||
import { wrap } from '../result'
|
||||
|
||||
const activeInteractive = new Map<string, AbortController>()
|
||||
@@ -29,7 +30,8 @@ export function registerInteractiveHandlers(
|
||||
registry: BookRegistryService,
|
||||
settings: GlobalSettingsService,
|
||||
aiClient: AiClientService,
|
||||
writingLogs: WritingLogService
|
||||
writingLogs: WritingLogService,
|
||||
writingSessions: WritingSessionService
|
||||
): void {
|
||||
ipcMain.handle(IPC.INTERACTIVE_CHECK_GATE, (_e, { bookId }: { bookId: string }) =>
|
||||
wrap(() => checkInteractiveGate(registry.getDb(bookId)))
|
||||
@@ -208,7 +210,10 @@ export function registerInteractiveHandlers(
|
||||
wrap(() => {
|
||||
const result = serviceFor(registry, bookId, aiClient).finishChapter(flowId, volumeId, title)
|
||||
const ch = registry.getChapterRepo(bookId).get(result.chapterId)!
|
||||
trackerFor(registry, bookId, writingLogs).supplementOnFinish(result.chapterId, ch.wordCount)
|
||||
trackerFor(registry, bookId, writingLogs, writingSessions).supplementOnFinish(
|
||||
result.chapterId,
|
||||
ch.wordCount
|
||||
)
|
||||
scheduleChapterExtraction(registry, bookId, result.chapterId, aiClient, settings)
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -2,13 +2,15 @@ import type { SqliteDb } from '../db/types'
|
||||
import { ChapterWritingSnapshotRepository } from '../db/repositories/chapter-writing-snapshot.repo'
|
||||
import type { BookRegistryService } from './book-registry'
|
||||
import type { WritingLogService } from './writing-log.service'
|
||||
import type { WritingSessionService } from './writing-session.service'
|
||||
|
||||
export function trackerFor(
|
||||
registry: BookRegistryService,
|
||||
bookId: string,
|
||||
writingLogs: WritingLogService
|
||||
writingLogs: WritingLogService,
|
||||
sessions: WritingSessionService
|
||||
): ChapterWritingTracker {
|
||||
return new ChapterWritingTracker(registry.getDb(bookId), bookId, writingLogs)
|
||||
return new ChapterWritingTracker(registry.getDb(bookId), bookId, writingLogs, sessions)
|
||||
}
|
||||
|
||||
export class ChapterWritingTracker {
|
||||
@@ -17,7 +19,8 @@ export class ChapterWritingTracker {
|
||||
constructor(
|
||||
bookDb: SqliteDb,
|
||||
private bookId: string,
|
||||
private writingLogs: WritingLogService
|
||||
private writingLogs: WritingLogService,
|
||||
private sessions: WritingSessionService
|
||||
) {
|
||||
this.snapRepo = new ChapterWritingSnapshotRepository(bookDb)
|
||||
}
|
||||
@@ -26,7 +29,10 @@ export class ChapterWritingTracker {
|
||||
const snap = this.snapRepo.get(chapterId)
|
||||
const logged = snap?.loggedWordCount ?? 0
|
||||
const delta = newWordCount - logged
|
||||
if (delta !== 0) this.writingLogs.addToday(this.bookId, delta)
|
||||
if (delta !== 0) {
|
||||
this.writingLogs.addToday(this.bookId, delta)
|
||||
this.sessions.recordActivity(this.bookId, chapterId, delta)
|
||||
}
|
||||
this.snapRepo.upsert(chapterId, newWordCount, snap?.finishSupplemented ?? false)
|
||||
}
|
||||
|
||||
@@ -34,7 +40,10 @@ export class ChapterWritingTracker {
|
||||
const snap = this.snapRepo.get(chapterId)
|
||||
const logged = snap?.loggedWordCount ?? 0
|
||||
const gap = wordCount - logged
|
||||
if (gap > 0) this.writingLogs.addToday(this.bookId, gap)
|
||||
if (gap > 0) {
|
||||
this.writingLogs.addToday(this.bookId, gap)
|
||||
this.sessions.recordActivity(this.bookId, chapterId, gap)
|
||||
}
|
||||
this.snapRepo.upsert(chapterId, wordCount, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import type {
|
||||
CharacterGraphData,
|
||||
CharacterGraphEdge,
|
||||
CharacterRelationship,
|
||||
SettingEntry
|
||||
} from '../../shared/types'
|
||||
import { SettingRepository } from '../db/repositories/setting.repo'
|
||||
import type { BookRegistryService } from './book-registry'
|
||||
|
||||
const MAX_NODES = 100
|
||||
|
||||
function parseRelationships(entry: SettingEntry): CharacterRelationship[] {
|
||||
const raw = entry.properties?.relationships
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.filter(
|
||||
(r): r is CharacterRelationship =>
|
||||
r != null &&
|
||||
typeof r === 'object' &&
|
||||
typeof (r as CharacterRelationship).id === 'string' &&
|
||||
typeof (r as CharacterRelationship).targetId === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function dedupeEdges(
|
||||
raw: Array<{ source: string; target: string; label: string; intimacy: number; id: string }>
|
||||
): CharacterGraphEdge[] {
|
||||
const best = new Map<string, CharacterGraphEdge>()
|
||||
for (const e of raw) {
|
||||
const key = [e.source, e.target].sort().join('|')
|
||||
const existing = best.get(key)
|
||||
if (!existing || e.intimacy > existing.intimacy) {
|
||||
best.set(key, {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: e.label,
|
||||
intimacy: e.intimacy as 1 | 2 | 3 | 4 | 5
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...best.values()]
|
||||
}
|
||||
|
||||
export class CharacterGraphService {
|
||||
constructor(private registry: BookRegistryService) {}
|
||||
|
||||
getData(bookId: string, filter: 'all' | 'connected' = 'all'): CharacterGraphData {
|
||||
const repo = new SettingRepository(this.registry.getDb(bookId))
|
||||
const characters = repo.list('character')
|
||||
const nodeCount = characters.length
|
||||
const truncated = nodeCount > MAX_NODES
|
||||
|
||||
let nodes = characters.map((c) => ({ id: c.id, label: c.name }))
|
||||
const rawEdges: Array<{
|
||||
source: string
|
||||
target: string
|
||||
label: string
|
||||
intimacy: number
|
||||
id: string
|
||||
}> = []
|
||||
|
||||
for (const c of characters) {
|
||||
for (const rel of parseRelationships(c)) {
|
||||
if (!characters.some((x) => x.id === rel.targetId)) continue
|
||||
rawEdges.push({
|
||||
source: c.id,
|
||||
target: rel.targetId,
|
||||
label: rel.label,
|
||||
intimacy: rel.intimacy,
|
||||
id: rel.id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let edges = dedupeEdges(rawEdges)
|
||||
|
||||
if (filter === 'connected') {
|
||||
const connected = new Set<string>()
|
||||
for (const e of edges) {
|
||||
connected.add(e.source)
|
||||
connected.add(e.target)
|
||||
}
|
||||
nodes = nodes.filter((n) => connected.has(n.id))
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
nodes = nodes.slice(0, MAX_NODES)
|
||||
const allowed = new Set(nodes.map((n) => n.id))
|
||||
edges = edges.filter((e) => allowed.has(e.source) && allowed.has(e.target))
|
||||
}
|
||||
|
||||
return { nodes, edges, truncated, nodeCount }
|
||||
}
|
||||
|
||||
upsertRelationship(
|
||||
bookId: string,
|
||||
sourceId: string,
|
||||
input: Partial<CharacterRelationship> & {
|
||||
targetId: string
|
||||
label: string
|
||||
intimacy: number
|
||||
}
|
||||
): CharacterRelationship {
|
||||
const repo = new SettingRepository(this.registry.getDb(bookId))
|
||||
const source = repo.get(sourceId)
|
||||
const target = repo.get(input.targetId)
|
||||
if (!source || source.type !== 'character') throw new Error('Invalid source character')
|
||||
if (!target || target.type !== 'character') throw new Error('Invalid target character')
|
||||
if (sourceId === input.targetId) throw new Error('Cannot relate character to itself')
|
||||
|
||||
const intimacy = Math.min(5, Math.max(1, Math.round(input.intimacy))) as CharacterRelationship['intimacy']
|
||||
const relationships = parseRelationships(source)
|
||||
const id = input.id ?? randomUUID()
|
||||
const next: CharacterRelationship = {
|
||||
id,
|
||||
targetId: input.targetId,
|
||||
label: input.label.trim(),
|
||||
intimacy
|
||||
}
|
||||
const idx = relationships.findIndex((r) => r.id === id)
|
||||
if (idx >= 0) relationships[idx] = next
|
||||
else relationships.push(next)
|
||||
|
||||
repo.update(sourceId, {
|
||||
properties: { ...source.properties, relationships }
|
||||
})
|
||||
return next
|
||||
}
|
||||
|
||||
deleteRelationship(bookId: string, sourceId: string, relationshipId: string): void {
|
||||
const repo = new SettingRepository(this.registry.getDb(bookId))
|
||||
const source = repo.get(sourceId)
|
||||
if (!source) throw new Error('Setting not found')
|
||||
const relationships = parseRelationships(source).filter((r) => r.id !== relationshipId)
|
||||
repo.update(sourceId, {
|
||||
properties: { ...source.properties, relationships }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { WritingAnalyticsSummary } from '../../shared/types'
|
||||
import { ChapterRepository } from '../db/repositories/chapter.repo'
|
||||
import { PomodoroDailyRepository } from '../db/repositories/pomodoro-daily.repo'
|
||||
import { SettingRepository } from '../db/repositories/setting.repo'
|
||||
import { VolumeRepository } from '../db/repositories/volume.repo'
|
||||
import { WritingSessionRepository } from '../db/repositories/writing-session.repo'
|
||||
import type { BookRegistryService } from './book-registry'
|
||||
import { addDays, localDateString } from './writing-date.util'
|
||||
import type { WritingLogService } from './writing-log.service'
|
||||
|
||||
export const POV_NONE = '__none__'
|
||||
|
||||
function minutesBetween(startIso: string, endIso: string): number {
|
||||
const ms = new Date(endIso).getTime() - new Date(startIso).getTime()
|
||||
return Math.max(0, ms / 60000)
|
||||
}
|
||||
|
||||
function computeWpm(
|
||||
sessions: Array<{ wordDelta: number; startedAt: string; endedAt: string }>
|
||||
): number | null {
|
||||
const totalWords = sessions.reduce((s, x) => s + x.wordDelta, 0)
|
||||
const totalMin = sessions.reduce((s, x) => s + minutesBetween(x.startedAt, x.endedAt), 0)
|
||||
if (totalMin <= 0 || totalWords <= 0) return null
|
||||
return Math.round(totalWords / totalMin)
|
||||
}
|
||||
|
||||
export class WritingAnalyticsService {
|
||||
constructor(
|
||||
private writingLogs: WritingLogService,
|
||||
private sessionRepo: WritingSessionRepository,
|
||||
private pomodoroDaily: PomodoroDailyRepository,
|
||||
private registry: BookRegistryService
|
||||
) {}
|
||||
|
||||
getSummary(bookId: string): WritingAnalyticsSummary {
|
||||
const today = localDateString()
|
||||
const todaySessions = this.sessionRepo.listForBookDate(bookId, today)
|
||||
const todayWords = this.writingLogs.getStats(bookId).todayWords
|
||||
const todaySpeedWpm = computeWpm(todaySessions)
|
||||
|
||||
const weekTrend: { date: string; words: number }[] = []
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = addDays(today, -i)
|
||||
weekTrend.push({ date, words: this.writingLogs.getDayWords(bookId, date) })
|
||||
}
|
||||
|
||||
const sinceIso = addDays(today, -30) + 'T00:00:00.000Z'
|
||||
const sessions30 = this.sessionRepo.listSince(bookId, sinceIso)
|
||||
const hourlyHeatmap = Array.from({ length: 24 }, () => 0)
|
||||
for (const s of sessions30) {
|
||||
const hour = new Date(s.startedAt).getHours()
|
||||
hourlyHeatmap[hour] += s.wordDelta
|
||||
}
|
||||
|
||||
const pomodoroToday = this.pomodoroDaily.getTodayCount(bookId)
|
||||
const pomodoroStats = this.pomodoroDaily.getTodayStats(bookId)
|
||||
const pomodoroAvgWords =
|
||||
pomodoroStats.completedCount > 0
|
||||
? Math.round(pomodoroStats.totalWords / pomodoroStats.completedCount)
|
||||
: null
|
||||
|
||||
const db = this.registry.getDb(bookId)
|
||||
const chapters = new ChapterRepository(db).list()
|
||||
const settings = new SettingRepository(db).list('character')
|
||||
const nameById = new Map(settings.map((s) => [s.id, s.name]))
|
||||
|
||||
const povMap = new Map<string, number>()
|
||||
for (const ch of chapters) {
|
||||
const key = ch.povCharacterId ?? POV_NONE
|
||||
povMap.set(key, (povMap.get(key) ?? 0) + ch.wordCount)
|
||||
}
|
||||
const povDistribution = [...povMap.entries()]
|
||||
.map(([characterId, words]) => ({
|
||||
characterId,
|
||||
name: characterId === POV_NONE ? '' : (nameById.get(characterId) ?? characterId),
|
||||
words
|
||||
}))
|
||||
.sort((a, b) => b.words - a.words)
|
||||
|
||||
const volumes = new VolumeRepository(db).list()
|
||||
const volTitle = new Map(volumes.map((v) => [v.id, v.name]))
|
||||
const volMap = new Map<string, number>()
|
||||
for (const ch of chapters) {
|
||||
if (!ch.volumeId) continue
|
||||
volMap.set(ch.volumeId, (volMap.get(ch.volumeId) ?? 0) + ch.wordCount)
|
||||
}
|
||||
const volumeDistribution = [...volMap.entries()]
|
||||
.map(([volumeId, words]) => ({
|
||||
volumeId,
|
||||
title: volTitle.get(volumeId) ?? volumeId,
|
||||
words
|
||||
}))
|
||||
.sort((a, b) => b.words - a.words)
|
||||
|
||||
return {
|
||||
todayWords,
|
||||
todaySpeedWpm,
|
||||
weekTrend,
|
||||
hourlyHeatmap,
|
||||
pomodoroToday,
|
||||
pomodoroAvgWords,
|
||||
povDistribution,
|
||||
volumeDistribution
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,10 @@ export class WritingLogService {
|
||||
return { todayWords, dailyGoal, goalProgress, streakDays, heatmap }
|
||||
}
|
||||
|
||||
getDayWords(bookId: string, date: string): number {
|
||||
return this.repo.getDay(bookId, date)
|
||||
}
|
||||
|
||||
private calcStreak(bookId: string, goal: number): number {
|
||||
let streak = 0
|
||||
let date = addDays(localDateString(), -1)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { WritingSessionRepository } from '../db/repositories/writing-session.repo'
|
||||
import { localDateString } from './writing-date.util'
|
||||
|
||||
const IDLE_MS = 5 * 60 * 1000
|
||||
|
||||
export class WritingSessionService {
|
||||
private active = new Map<string, { sessionId: string; lastEndedAt: number }>()
|
||||
|
||||
constructor(private repo: WritingSessionRepository) {}
|
||||
|
||||
recordActivity(bookId: string, chapterId: string | undefined, delta: number): void {
|
||||
if (delta === 0) return
|
||||
const now = new Date()
|
||||
const nowIso = now.toISOString()
|
||||
const cached = this.active.get(bookId)
|
||||
const idle = !cached || now.getTime() - cached.lastEndedAt > IDLE_MS
|
||||
|
||||
if (idle) {
|
||||
const id = randomUUID()
|
||||
this.repo.insert({
|
||||
id,
|
||||
bookId,
|
||||
chapterId,
|
||||
startedAt: nowIso,
|
||||
endedAt: nowIso,
|
||||
wordDelta: delta,
|
||||
date: localDateString()
|
||||
})
|
||||
this.active.set(bookId, { sessionId: id, lastEndedAt: now.getTime() })
|
||||
} else {
|
||||
this.repo.updateActivity(cached!.sessionId, nowIso, delta)
|
||||
this.active.set(bookId, { sessionId: cached!.sessionId, lastEndedAt: now.getTime() })
|
||||
}
|
||||
}
|
||||
|
||||
endActive(bookId: string): void {
|
||||
this.active.delete(bookId)
|
||||
}
|
||||
}
|
||||
+31
-1
@@ -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<IpcResult<boolean>> =>
|
||||
ipcRenderer.invoke(IPC.COCKPIT_SHOULD_SHOW, { bookId })
|
||||
},
|
||||
graph: {
|
||||
getData: (
|
||||
bookId: string,
|
||||
filter?: 'all' | 'connected'
|
||||
): Promise<IpcResult<CharacterGraphData>> =>
|
||||
ipcRenderer.invoke(IPC.GRAPH_GET_DATA, { bookId, filter }),
|
||||
upsertRelationship: (
|
||||
bookId: string,
|
||||
sourceId: string,
|
||||
relationship: Partial<CharacterRelationship> & {
|
||||
targetId: string
|
||||
label: string
|
||||
intimacy: number
|
||||
}
|
||||
): Promise<IpcResult<CharacterRelationship>> =>
|
||||
ipcRenderer.invoke(IPC.GRAPH_UPSERT_RELATIONSHIP, { bookId, sourceId, relationship }),
|
||||
deleteRelationship: (
|
||||
bookId: string,
|
||||
sourceId: string,
|
||||
relationshipId: string
|
||||
): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.GRAPH_DELETE_RELATIONSHIP, { bookId, sourceId, relationshipId })
|
||||
},
|
||||
analytics: {
|
||||
getSummary: (bookId: string): Promise<IpcResult<WritingAnalyticsSummary>> =>
|
||||
ipcRenderer.invoke(IPC.ANALYTICS_SUMMARY, { bookId })
|
||||
},
|
||||
bridge: {
|
||||
get: (
|
||||
bookId: string,
|
||||
|
||||
@@ -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 (
|
||||
<div className="cockpit-analytics" data-testid="cockpit-analytics">
|
||||
<h4>{t('analytics.title')}</h4>
|
||||
<div data-testid="cockpit-speed-today">{speedLabel}</div>
|
||||
<div>
|
||||
<div className="cockpit-analytics-label">{t('analytics.hourlyHeatmap')}</div>
|
||||
<HourlyHeatmap data={analytics.hourlyHeatmap} testId="cockpit-hourly-heatmap" />
|
||||
</div>
|
||||
<div data-testid="cockpit-pov-chart">
|
||||
<div className="cockpit-analytics-label">{t('analytics.povDistribution')}</div>
|
||||
{povTop.map((p) => (
|
||||
<div key={p.characterId} className="analytics-bar-row">
|
||||
<span>{povName(p.characterId, p.name)}</span>
|
||||
<div className="analytics-bar-track">
|
||||
<div className="pov-bar" style={{ width: `${(p.words / povMax) * 100}%` }} />
|
||||
</div>
|
||||
<span>{p.words}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div data-testid="cockpit-volume-chart">
|
||||
<div className="cockpit-analytics-label">{t('analytics.volumeDistribution')}</div>
|
||||
{analytics.volumeDistribution.map((v) => (
|
||||
<div key={v.volumeId} className="analytics-bar-row">
|
||||
<span>{v.title}</span>
|
||||
<div className="analytics-bar-track">
|
||||
<div className="volume-bar" style={{ width: `${(v.words / volMax) * 100}%` }} />
|
||||
</div>
|
||||
<span>{v.words}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div data-testid="cockpit-week-trend">
|
||||
<div className="cockpit-analytics-label">{t('analytics.weekTrend')}</div>
|
||||
<div className="week-trend-bars">
|
||||
{analytics.weekTrend.map((d) => (
|
||||
<div
|
||||
key={d.date}
|
||||
className="week-trend-bar"
|
||||
style={{ height: `${Math.max(4, (d.words / weekMax) * 48)}px` }}
|
||||
title={`${d.date}: ${d.words}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="cockpit-open-graph"
|
||||
onClick={() => useGraphStore.getState().openModal()}
|
||||
>
|
||||
{t('cockpit.openGraph')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<WritingAnalyticsSummary | null>(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
|
||||
<div className="cockpit-pomodoro-today" data-testid="cockpit-pomodoro-today">
|
||||
{t('cockpit.pomodoroToday', { count: summary.pomodoroTodayCount ?? 0 })}
|
||||
</div>
|
||||
<CockpitAnalytics analytics={analytics} />
|
||||
<div className="cockpit-achievements-section">
|
||||
<div className="cockpit-heatmap-label">{t('cockpit.achievements')}</div>
|
||||
<div className="cockpit-achievements" data-testid="cockpit-achievements">
|
||||
|
||||
@@ -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 (
|
||||
<div className="hourly-heatmap" data-testid={testId}>
|
||||
{data.map((value, hour) => (
|
||||
<div
|
||||
key={hour}
|
||||
className="hourly-heatmap-cell"
|
||||
title={`${hour}:00`}
|
||||
style={{
|
||||
opacity: value > 0 ? 0.35 + (value / max) * 0.65 : 0.2,
|
||||
background: value > 0 ? 'var(--accent)' : undefined
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 <div className="placeholder-box">{t('editor.placeholder')}</div>
|
||||
}
|
||||
@@ -354,6 +358,9 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
|
||||
return (
|
||||
<div className="editor-content-wrap">
|
||||
<EditorContent editor={editor} />
|
||||
{settingEntry?.type === 'character' && bookId && (
|
||||
<SettingRelationshipsEditor entry={settingEntry} bookId={bookId} />
|
||||
)}
|
||||
{mentionOpen && mentionCandidates.length > 0 && (
|
||||
<div className="mention-dropdown" data-testid="mention-dropdown">
|
||||
{mentionCandidates.map((s) => (
|
||||
|
||||
@@ -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<cytoscape.Core | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [selection, setSelection] = useState<GraphSelection>(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 (
|
||||
<div className="modal-overlay character-graph-modal" data-testid="character-graph-modal">
|
||||
<div className="modal-content modal-content--wide">
|
||||
<div className="modal-header">
|
||||
<h3>{t('graph.title')}</h3>
|
||||
<button type="button" className="modal-close" onClick={close}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="graph-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="graph-layout-cose"
|
||||
onClick={() => handleLayout('cose-bilkent')}
|
||||
>
|
||||
{t('graph.layout.cose')}
|
||||
</button>
|
||||
<button type="button" data-testid="graph-layout-grid" onClick={() => handleLayout('grid')}>
|
||||
{t('graph.layout.grid')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="graph-layout-circle"
|
||||
onClick={() => handleLayout('circle')}
|
||||
>
|
||||
{t('graph.layout.circle')}
|
||||
</button>
|
||||
<select
|
||||
className="form-control"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as 'all' | 'connected')}
|
||||
>
|
||||
<option value="all">{t('graph.filter.all')}</option>
|
||||
<option value="connected">{t('graph.filter.connected')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{data?.truncated && (
|
||||
<div className="graph-truncated-notice">{t('graph.truncated', { count: 100 })}</div>
|
||||
)}
|
||||
<div className="graph-body">
|
||||
<div
|
||||
id="character-graph-cy"
|
||||
ref={containerRef}
|
||||
data-testid="character-graph-cy"
|
||||
/>
|
||||
<aside className="graph-sidebar" data-testid="character-graph-sidebar">
|
||||
{loading && <div className="sidebar-empty">{t('cockpit.loading')}</div>}
|
||||
{!loading && !selection && (
|
||||
<div className="sidebar-empty">{t('graph.sidebar.hint')}</div>
|
||||
)}
|
||||
{selection?.kind === 'node' && (
|
||||
<>
|
||||
<div>{t('graph.sidebar.node', { name: selection.node.label })}</div>
|
||||
<button type="button" className="btn" onClick={handleEditSetting}>
|
||||
{t('graph.editSetting')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{selection?.kind === 'edge' && (
|
||||
<div>
|
||||
{t('graph.sidebar.edge', { label: selection.edge.label })}
|
||||
<div className="graph-intimacy-bar">
|
||||
<span
|
||||
style={{
|
||||
background: INTIMACY_COLORS[selection.edge.intimacy],
|
||||
width: `${selection.edge.intimacy * 20}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>{t('relationship.intimacy')}: {selection.edge.intimacy}</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<void> => {
|
||||
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 ? (
|
||||
<>
|
||||
<label className="status-pov-label">
|
||||
{t('chapter.pov')}
|
||||
<select
|
||||
data-testid="chapter-pov-select"
|
||||
value={currentChapter?.povCharacterId ?? ''}
|
||||
onChange={(e) => void handlePovChange(e.target.value || null)}
|
||||
>
|
||||
<option value="">{t('analytics.povUnassigned')}</option>
|
||||
{characterSettings.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
|
||||
<span>{t('status.volume')}: {t('editor.words', { count: wordCount.volume })}</span>
|
||||
<span>{t('status.book')}: {t('editor.words', { count: wordCount.book })}</span>
|
||||
@@ -532,6 +563,7 @@ export function EditorLayout(): React.JSX.Element {
|
||||
setBridgeOpen(true)
|
||||
}}
|
||||
/>
|
||||
<CharacterGraphModal />
|
||||
<ChapterBridgeModal
|
||||
open={bridgeOpen}
|
||||
chapterId={activeBridgeChapterId}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import type { SettingType } from '@shared/types'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { useGraphStore } from '@renderer/stores/useGraphStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
const SETTING_TYPES: SettingType[] = [
|
||||
@@ -84,6 +85,13 @@ export function SettingList(): React.JSX.Element {
|
||||
})}
|
||||
</div>
|
||||
<div className="sidebar-footer">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="setting-open-graph"
|
||||
onClick={() => useGraphStore.getState().openModal()}
|
||||
>
|
||||
{t('graph.title')}
|
||||
</button>
|
||||
<button type="button" data-testid="setting-new" onClick={() => setShowDialog(true)}>
|
||||
+ {t('setting.new')}
|
||||
</button>
|
||||
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.graph.deleteRelationship(bookId, entry.id, relationshipId)
|
||||
)
|
||||
syncLocal(relationships.filter((r) => r.id !== relationshipId))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="setting-relationships" data-testid="setting-relationships">
|
||||
<h4>{t('relationship.title')}</h4>
|
||||
{relationships.map((r) => (
|
||||
<div key={r.id} className="setting-relationship-row">
|
||||
<span>
|
||||
{targetName(r.targetId)} · {r.label} · {r.intimacy}
|
||||
</span>
|
||||
<button type="button" aria-label="delete" onClick={() => void handleDelete(r.id)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{showForm ? (
|
||||
<div className="setting-relationship-form">
|
||||
<select
|
||||
className="form-control"
|
||||
value={targetId}
|
||||
onChange={(e) => setTargetId(e.target.value)}
|
||||
>
|
||||
<option value="">{t('relationship.target')}</option>
|
||||
{characters.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder={t('relationship.label')}
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
/>
|
||||
<label>
|
||||
{t('relationship.intimacy')}
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={5}
|
||||
value={intimacy}
|
||||
onChange={(e) => setIntimacy(Number(e.target.value))}
|
||||
/>
|
||||
{intimacy}
|
||||
</label>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void handleAdd()}>
|
||||
{t('dialog.save')}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => setShowForm(false)}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="setting-relationship-add"
|
||||
onClick={() => setShowForm(true)}
|
||||
>
|
||||
+ {t('relationship.add')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -228,7 +228,7 @@ export function SettingsPage(): React.JSX.Element {
|
||||
{section === 'shortcuts' && <ShortcutEditor />}
|
||||
{section === 'about' && (
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
{t('app.name')} v0.9.0
|
||||
{t('app.name')} v1.0.0
|
||||
<br />
|
||||
{t('app.tagline')}
|
||||
</p>
|
||||
|
||||
@@ -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<number, string> = {
|
||||
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 }
|
||||
@@ -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<void>
|
||||
setFilter: (filter: 'all' | 'connected') => void
|
||||
}
|
||||
|
||||
export const useGraphStore = create<GraphState>((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 })
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+24
-1
@@ -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<IpcResult<void>>
|
||||
shouldShowOnOpen: (bookId: string) => Promise<IpcResult<boolean>>
|
||||
}
|
||||
graph: {
|
||||
getData: (bookId: string, filter?: 'all' | 'connected') => Promise<IpcResult<CharacterGraphData>>
|
||||
upsertRelationship: (
|
||||
bookId: string,
|
||||
sourceId: string,
|
||||
relationship: Partial<CharacterRelationship> & {
|
||||
targetId: string
|
||||
label: string
|
||||
intimacy: number
|
||||
}
|
||||
) => Promise<IpcResult<CharacterRelationship>>
|
||||
deleteRelationship: (
|
||||
bookId: string,
|
||||
sourceId: string,
|
||||
relationshipId: string
|
||||
) => Promise<IpcResult<void>>
|
||||
}
|
||||
analytics: {
|
||||
getSummary: (bookId: string) => Promise<IpcResult<WritingAnalyticsSummary>>
|
||||
}
|
||||
bridge: {
|
||||
get: (
|
||||
bookId: string,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -160,6 +160,44 @@ export interface GoalNotificationPayload {
|
||||
messageParams?: Record<string, string | number>
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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, '<p>测试内容</p>')
|
||||
const tracker = trackerFor(registry, bookId, writingLogs, sessions)
|
||||
tracker.recordDelta(chapter.id, chapter.wordCount)
|
||||
vi.advanceTimersByTime(2 * 60 * 1000)
|
||||
chapter = chapterRepo.update(chapter.id, { content: '<p>测试内容扩展更多汉字</p>' })
|
||||
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, '<p>一二三四五</p>')
|
||||
const ch2 = new ChapterRepository(db).create(volId, 'B', 1, '<p>六七八九十</p>')
|
||||
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, '<p>一二三四五</p>')
|
||||
const tracker = trackerFor(registry, bookId, writingLogs, sessions)
|
||||
tracker.recordDelta(chapter.id, chapter.wordCount)
|
||||
vi.advanceTimersByTime(2 * 60 * 1000)
|
||||
chapter = chapterRepo.update(chapter.id, { content: '<p>一二三四五六七八九十</p>' })
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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, '<p>一二三四五六七八九十</p>')
|
||||
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, '<p>ab</p>')
|
||||
const tracker = new ChapterWritingTracker(db, bookId, writingLogs)
|
||||
const tracker = new ChapterWritingTracker(db, bookId, writingLogs, sessions)
|
||||
const updated = chapterRepo.update(ch.id, { content: '<p>abcd</p>' })
|
||||
tracker.recordDelta(ch.id, updated.wordCount)
|
||||
const updated2 = chapterRepo.update(ch.id, { content: '<p>abcdef</p>' })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user