From aac51bf183685f4fd09272f211db1e12efeb0109 Mon Sep 17 00:00:00 2001 From: kun1h Date: Mon, 6 Jul 2026 14:13:27 +0800 Subject: [PATCH] feat: deliver P2 v0.2.0 with unified editor, search, and snapshots Implement schema v2 migration, outline/setting/inspiration CRUD, unified TipTap editing, reference panel with mentions, FTS global search, chapter version history, writing landmarks, word frequency analysis, light theme contrast fixes, and full unit/E2E test coverage. Co-authored-by: Cursor --- README.md | 11 +- e2e/outline-coverage.spec.ts | 73 +++ e2e/outline.spec.ts | 87 +++ e2e/p2-features.spec.ts | 189 ++++++ e2e/themes-contrast.spec.ts | 146 +++++ package-lock.json | 577 ++++++++++++++++-- package.json | 4 +- public/locales/en/translation.json | 58 +- public/locales/zh-CN/translation.json | 58 +- src/main/db/migrate.ts | 37 +- src/main/db/repositories/bookmark.repo.ts | 92 +++ src/main/db/repositories/inspiration.repo.ts | 91 +++ src/main/db/repositories/outline.repo.ts | 137 +++++ src/main/db/repositories/setting.repo.ts | 103 ++++ src/main/db/repositories/snapshot.repo.ts | 81 +++ src/main/db/schema-v2.sql | 70 +++ src/main/index.ts | 7 +- src/main/ipc/handlers/bookmark.handler.ts | 81 +++ src/main/ipc/handlers/chapter.handler.ts | 13 +- src/main/ipc/handlers/inspiration.handler.ts | 86 +++ src/main/ipc/handlers/outline.handler.ts | 82 +++ src/main/ipc/handlers/search.handler.ts | 99 +++ src/main/ipc/handlers/setting.handler.ts | 58 ++ src/main/ipc/handlers/snapshot.handler.ts | 76 +++ src/main/ipc/register.ts | 19 + src/main/services/book-registry.ts | 37 +- src/main/services/fts-sync.service.ts | 68 +++ src/main/services/global-settings.ts | 6 +- src/main/services/search.service.ts | 159 +++++ src/main/services/snapshot.service.ts | 72 +++ src/main/services/word-count.ts | 6 +- src/main/services/wordfreq.service.ts | 64 ++ src/preload/index.ts | 164 ++++- src/renderer/App.tsx | 63 +- .../components/editor/TipTapEditor.tsx | 376 +++++++++--- .../inspiration/InspirationList.tsx | 55 ++ .../components/landmark/LandmarkModal.tsx | 72 +++ .../components/layout/EditorLayout.tsx | 198 ++++-- src/renderer/components/layout/RightPanel.tsx | 16 +- .../onboarding/OnboardingWizard.tsx | 3 +- .../components/outline/OutlineCompareView.tsx | 93 +++ .../components/outline/OutlineTree.tsx | 177 ++++++ .../components/reference/ReferencePanel.tsx | 92 +++ .../components/search/SearchModal.tsx | 132 ++++ .../components/setting/SettingList.tsx | 131 ++++ .../components/settings/SettingsPage.tsx | 8 +- .../components/version/VersionModal.tsx | 119 ++++ .../components/wordfreq/WordFreqPanel.tsx | 63 ++ src/renderer/extensions/landmark-mark.ts | 27 + src/renderer/extensions/mention.ts | 27 + src/renderer/lib/editor-commands.ts | 23 + src/renderer/lib/outline-utils.ts | 36 ++ src/renderer/stores/useAppStore.ts | 8 + src/renderer/stores/useBookStore.ts | 65 +- src/renderer/stores/useEditStore.ts | 27 + src/renderer/stores/useOutlineStore.ts | 28 + src/renderer/stores/useReferenceStore.ts | 30 + src/renderer/stores/useSearchStore.ts | 23 + src/renderer/styles/globals.css | 21 +- src/renderer/styles/layout.css | 476 ++++++++++++++- src/renderer/styles/themes.css | 66 ++ src/shared/electron-api.d.ts | 126 +++- src/shared/ipc-channels.ts | 33 +- src/shared/types.ts | 92 +++ tests/main/bookmark.repo.test.ts | 44 ++ tests/main/inspiration.repo.test.ts | 46 ++ tests/main/migrate-v2.test.ts | 57 ++ tests/main/outline.repo.test.ts | 50 ++ tests/main/search.service.test.ts | 46 ++ tests/main/setting.repo.test.ts | 47 ++ tests/main/snapshot.repo.test.ts | 45 ++ tests/main/wordfreq.service.test.ts | 41 ++ 72 files changed, 5790 insertions(+), 203 deletions(-) create mode 100644 e2e/outline-coverage.spec.ts create mode 100644 e2e/outline.spec.ts create mode 100644 e2e/p2-features.spec.ts create mode 100644 e2e/themes-contrast.spec.ts create mode 100644 src/main/db/repositories/bookmark.repo.ts create mode 100644 src/main/db/repositories/inspiration.repo.ts create mode 100644 src/main/db/repositories/outline.repo.ts create mode 100644 src/main/db/repositories/setting.repo.ts create mode 100644 src/main/db/repositories/snapshot.repo.ts create mode 100644 src/main/db/schema-v2.sql create mode 100644 src/main/ipc/handlers/bookmark.handler.ts create mode 100644 src/main/ipc/handlers/inspiration.handler.ts create mode 100644 src/main/ipc/handlers/outline.handler.ts create mode 100644 src/main/ipc/handlers/search.handler.ts create mode 100644 src/main/ipc/handlers/setting.handler.ts create mode 100644 src/main/ipc/handlers/snapshot.handler.ts create mode 100644 src/main/services/fts-sync.service.ts create mode 100644 src/main/services/search.service.ts create mode 100644 src/main/services/snapshot.service.ts create mode 100644 src/main/services/wordfreq.service.ts create mode 100644 src/renderer/components/inspiration/InspirationList.tsx create mode 100644 src/renderer/components/landmark/LandmarkModal.tsx create mode 100644 src/renderer/components/outline/OutlineCompareView.tsx create mode 100644 src/renderer/components/outline/OutlineTree.tsx create mode 100644 src/renderer/components/reference/ReferencePanel.tsx create mode 100644 src/renderer/components/search/SearchModal.tsx create mode 100644 src/renderer/components/setting/SettingList.tsx create mode 100644 src/renderer/components/version/VersionModal.tsx create mode 100644 src/renderer/components/wordfreq/WordFreqPanel.tsx create mode 100644 src/renderer/extensions/landmark-mark.ts create mode 100644 src/renderer/extensions/mention.ts create mode 100644 src/renderer/lib/editor-commands.ts create mode 100644 src/renderer/lib/outline-utils.ts create mode 100644 src/renderer/stores/useEditStore.ts create mode 100644 src/renderer/stores/useOutlineStore.ts create mode 100644 src/renderer/stores/useReferenceStore.ts create mode 100644 src/renderer/stores/useSearchStore.ts create mode 100644 tests/main/bookmark.repo.test.ts create mode 100644 tests/main/inspiration.repo.test.ts create mode 100644 tests/main/migrate-v2.test.ts create mode 100644 tests/main/outline.repo.test.ts create mode 100644 tests/main/search.service.test.ts create mode 100644 tests/main/setting.repo.test.ts create mode 100644 tests/main/snapshot.repo.test.ts create mode 100644 tests/main/wordfreq.service.test.ts diff --git a/README.md b/README.md index d2779fc..69c1384 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 笔临 (Bilin) -长篇创作智能协作平台 — Electron 桌面客户端 v0.1.0(P0/P1) +长篇创作智能协作平台 — Electron 桌面客户端 v0.2.0(P2) ## 开发 @@ -14,13 +14,14 @@ npm run dev ## 测试 ```bash -npm run test # 单元测试 6/6 -npm run test:e2e # E2E 测试(需先 build) npm run build +npm run test # 单元测试 +npm run test:e2e # E2E 测试(需先 build) ``` ## 文档 -- 设计规格:`docs/superpowers/specs/2026-07-05-bilin-p0-p1-design.md` -- 实现计划:`docs/superpowers/plans/2026-07-05-bilin-p0-p1.md` +- P2 设计规格:`docs/superpowers/specs/2026-07-06-bilin-p2-design.md` +- P2 实现计划:`docs/superpowers/plans/2026-07-06-bilin-p2.md` +- P0/P1 设计规格:`docs/superpowers/specs/2026-07-05-bilin-p0-p1-design.md` - UI 原型:`design/ui_pc.html` diff --git a/e2e/outline-coverage.spec.ts b/e2e/outline-coverage.spec.ts new file mode 100644 index 0000000..c9e27f6 --- /dev/null +++ b/e2e/outline-coverage.spec.ts @@ -0,0 +1,73 @@ +import path from 'path' +import { mkdtempSync, rmSync, existsSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { test, expect, _electron as electron, type Page } from '@playwright/test' + +const ROOT = path.join(import.meta.dirname, '..') + +async function launchApp(userDataDir: string) { + return electron.launch({ + args: [ROOT], + env: { ...process.env, BILIN_E2E: '1', BILIN_E2E_USER_DATA: userDataDir } + }) +} + +async function skipOnboarding(page: Page): Promise { + await page.waitForLoadState('domcontentloaded') + if (await page.getByText('欢迎使用笔临').isVisible()) { + await page.getByRole('button', { name: '跳过' }).click() + } +} + +async function createAndOpenBook(page: Page): Promise { + await page.getByRole('button', { name: /新建书籍/ }).first().click() + await page.locator('.dialog-content input').first().fill('覆盖度测试') + await page.getByRole('button', { name: '创建' }).click() + await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) +} + +test.describe('Outline coverage', () => { + let userDataDir: string + + test.beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-cov-')) + }) + + test.afterEach(() => { + if (userDataDir && existsSync(userDataDir)) { + try { + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) + } catch { + /* ignore */ + } + } + }) + + test('E2E-COV-01: uncovered outline shows warning border', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page) + await page.getByTestId('sidebar-tab-outline').click() + await page.getByTestId('outline-new').click() + await expect(page.locator('.outline-item[data-uncovered="true"]').first()).toBeVisible({ + timeout: 10_000 + }) + await expect(page.locator('[data-testid="outline-warn"]').first()).toBeVisible() + await app.close() + }) + + test('E2E-COV-02: filter uncovered hides covered items', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page) + await page.getByTestId('sidebar-tab-outline').click() + await page.getByTestId('outline-new').click() + await expect(page.locator('.outline-item').first()).toBeVisible({ timeout: 10_000 }) + await page.getByTestId('outline-filter').selectOption('uncovered') + await expect(page.locator('.outline-item[data-uncovered="true"]')).toHaveCount(1) + await app.close() + }) +}) diff --git a/e2e/outline.spec.ts b/e2e/outline.spec.ts new file mode 100644 index 0000000..dce9192 --- /dev/null +++ b/e2e/outline.spec.ts @@ -0,0 +1,87 @@ +import path from 'path' +import { mkdtempSync, rmSync, existsSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { test, expect, _electron as electron, type Page } from '@playwright/test' + +const ROOT = path.join(import.meta.dirname, '..') + +async function launchApp(userDataDir: string) { + return electron.launch({ + args: [ROOT], + env: { + ...process.env, + BILIN_E2E: '1', + BILIN_E2E_USER_DATA: userDataDir + } + }) +} + +async function skipOnboarding(page: Page): Promise { + await page.waitForLoadState('domcontentloaded') + await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 }) + if (await page.getByText('欢迎使用笔临').isVisible()) { + await page.getByRole('button', { name: '跳过' }).click() + await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 }) + } +} + +async function createAndOpenBook(page: Page, name: string): Promise { + await page.getByRole('button', { name: /新建书籍/ }).first().click() + await page.locator('.dialog-content input').first().fill(name) + await page.getByRole('button', { name: '创建' }).click() + await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) +} + +test.describe('Outline persistence', () => { + let userDataDir: string + + test.beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-outline-')) + }) + + test.afterEach(() => { + if (userDataDir && existsSync(userDataDir)) { + try { + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) + } catch { + /* ignore */ + } + } + }) + + test('E2E-OUTLINE: outline content persists after restart', async () => { + const app1 = await launchApp(userDataDir) + const page1 = await app1.firstWindow() + await skipOnboarding(page1) + await createAndOpenBook(page1, '持久化测试书') + + await page1.getByTestId('sidebar-tab-outline').click() + await expect(page1.getByTestId('outline-new')).toBeVisible({ timeout: 5_000 }) + await page1.getByTestId('outline-new').click() + await expect(page1.locator('#editor-layout')).toBeVisible({ timeout: 5_000 }) + await expect(page1.locator('.outline-item').first()).toBeVisible({ timeout: 15_000 }) + + const editor = page1.locator('.ProseMirror') + await expect(editor).toBeVisible({ timeout: 10_000 }) + await editor.click() + await editor.pressSequentially('主角踏上旅程的关键转折点') + await page1.waitForTimeout(3000) + + await app1.close() + + const app2 = await launchApp(userDataDir) + const page2 = await app2.firstWindow() + await skipOnboarding(page2) + await page2.getByText('持久化测试书').click() + await expect(page2.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) + + await page2.getByTestId('sidebar-tab-outline').click() + await page2.locator('.outline-item').first().click() + await expect(page2.locator('.ProseMirror')).toContainText('主角踏上旅程的关键转折点', { + timeout: 10_000 + }) + + await app2.close() + }) +}) diff --git a/e2e/p2-features.spec.ts b/e2e/p2-features.spec.ts new file mode 100644 index 0000000..7aa01d7 --- /dev/null +++ b/e2e/p2-features.spec.ts @@ -0,0 +1,189 @@ +import path from 'path' +import { mkdtempSync, rmSync, existsSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { test, expect, _electron as electron, type Page } from '@playwright/test' + +const ROOT = path.join(import.meta.dirname, '..') + +async function launchApp(userDataDir: string) { + return electron.launch({ + args: [ROOT], + env: { ...process.env, BILIN_E2E: '1', BILIN_E2E_USER_DATA: userDataDir } + }) +} + +async function skipOnboarding(page: Page): Promise { + await page.waitForLoadState('domcontentloaded') + if (await page.getByText('欢迎使用笔临').isVisible()) { + await page.getByRole('button', { name: '跳过' }).click() + } +} + +async function createAndOpenBook(page: Page, name = 'P2测试书'): Promise { + await page.getByRole('button', { name: /新建书籍/ }).first().click() + await page.locator('.dialog-content input').first().fill(name) + await page.getByRole('button', { name: '创建' }).click() + await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 }) +} + +async function ensureChapterEditor(page: Page): Promise { + await page.getByRole('button', { name: '+ 新章' }).click() + await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 }) +} + +test.describe('P2 features', () => { + let userDataDir: string + + test.beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-p2-')) + }) + + test.afterEach(() => { + if (userDataDir && existsSync(userDataDir)) { + try { + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) + } catch { + /* ignore */ + } + } + }) + + test('E2E-P2-REF-01: reference panel drag inserts setting text', 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.getByTestId(/setting-item-/)).toBeVisible({ timeout: 10_000 }) + + const editor = page.locator('.ProseMirror') + await editor.click() + await page.getByTestId('open-reference').click() + await expect(page.getByTestId('reference-panel')).toBeVisible() + + const refItem = page.locator('[data-testid^="ref-item-"]').first() + await refItem.dragTo(editor) + await page.evaluate(() => { + const editorEl = document.querySelector('.ProseMirror') + if (!editorEl) return + const rect = editorEl.getBoundingClientRect() + const dt = new DataTransfer() + dt.setData('text/plain', '主角林远') + editorEl.dispatchEvent( + new DragEvent('drop', { + bubbles: true, + cancelable: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + dataTransfer: dt + }) + ) + }) + + await expect(editor).toContainText('主角', { timeout: 5_000 }) + await app.close() + }) + + test('E2E-P2-SEARCH-01: global search finds chapter text and jumps', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page) + await ensureChapterEditor(page) + + const unique = '搜索专用词XYZ' + const editor = page.locator('.ProseMirror') + await editor.click() + await editor.pressSequentially(unique) + await page.keyboard.press('Control+S') + await expect(page.getByText('已保存')).toBeVisible({ timeout: 10_000 }) + + await page.evaluate(() => window.dispatchEvent(new Event('bilin:e2e-open-search'))) + await expect(page.getByTestId('search-modal')).toBeVisible({ timeout: 5_000 }) + await page.getByTestId('search-input').fill(unique) + await expect(page.locator('.search-result').first()).toBeVisible({ timeout: 10_000 }) + await page.keyboard.press('Enter') + await expect(page.getByTestId('search-modal')).toBeHidden({ timeout: 5_000 }) + await expect(editor).toContainText(unique) + await app.close() + }) + + test('E2E-P2-SNAP-01: manual snapshot shows diff', 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('版本快照内容A') + await page.waitForTimeout(2500) + + await page.getByTestId('open-version').click() + await expect(page.getByTestId('version-modal')).toBeVisible() + await page.getByRole('button', { name: '创建快照' }).click() + await expect(page.locator('.version-item').first()).toBeVisible({ timeout: 10_000 }) + await page.getByRole('button', { name: '取消' }).click() + await expect(page.getByTestId('version-modal')).toBeHidden() + + await editor.click() + await editor.press('Control+a') + await editor.pressSequentially('版本快照内容B') + await page.waitForTimeout(2500) + + await page.getByTestId('open-version').click() + await page.locator('.version-item').first().click() + await expect(page.locator('.diff-view')).toContainText('内容') + await app.close() + }) + + test('E2E-P2-LAND-01: insert landmark appears in list modal', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page) + await ensureChapterEditor(page) + + await page.evaluate(() => { + window.dispatchEvent( + new CustomEvent('bilin:e2e-insert-landmark', { detail: { label: '待查伏笔' } }) + ) + }) + await expect(page.locator('.landmark-mark')).toContainText('待查伏笔', { timeout: 5_000 }) + + await page.evaluate(() => window.dispatchEvent(new Event('bilin:e2e-open-landmarks'))) + await expect(page.getByTestId('landmarks-modal')).toBeVisible({ timeout: 5_000 }) + await expect(page.locator('[data-testid^="landmark-item-"]')).toContainText('待查伏笔') + await app.close() + }) + + test('E2E-P2-WORD-01: word frequency click selects token in editor', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createAndOpenBook(page) + await ensureChapterEditor(page) + + const token = '测' + const editor = page.locator('.ProseMirror') + await editor.click() + await editor.pressSequentially(token.repeat(20)) + await page.keyboard.press('Control+S') + await expect(page.getByText('已保存')).toBeVisible({ timeout: 10_000 }) + + await page.getByTestId('panel-tab-wordfreq').click() + await expect(page.getByTestId('wordfreq-panel')).toBeVisible() + await expect(page.getByTestId(`wordfreq-${token}`)).toBeVisible({ timeout: 10_000 }) + await page.getByTestId(`wordfreq-${token}`).click() + await expect(page.locator('.editor-content-wrap')).toHaveAttribute('data-highlight-token', token) + await app.close() + }) +}) diff --git a/e2e/themes-contrast.spec.ts b/e2e/themes-contrast.spec.ts new file mode 100644 index 0000000..34f5915 --- /dev/null +++ b/e2e/themes-contrast.spec.ts @@ -0,0 +1,146 @@ +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, '..') + +const LIGHT_THEMES = ['ricepaper', 'mist', 'teagarden'] as const + +async function launchApp(userDataDir: string) { + return electron.launch({ + args: [ROOT], + env: { + ...process.env, + BILIN_E2E: '1', + BILIN_E2E_USER_DATA: userDataDir + } + }) +} + +async function skipOnboarding(page: Page): Promise { + await page.waitForLoadState('domcontentloaded') + await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 }) + if (await page.getByText('欢迎使用笔临').isVisible()) { + await page.getByRole('button', { name: '跳过' }).click() + await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 }) + } +} + +async function openSettings(page: Page): Promise { + await page.getByRole('button', { name: /系统设置/ }).click() + await expect(page.locator('#settings-page')).toBeVisible() +} + +function parseRgb(color: string): [number, number, number] | null { + const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/) + if (!m) return null + return [Number(m[1]), Number(m[2]), Number(m[3])] +} + +function relativeLuminance(r: number, g: number, b: number): number { + const [rs, gs, bs] = [r, g, b].map((c) => { + const s = c / 255 + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4) + }) + return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs +} + +function contrastRatio(fg: string, bg: string): number { + const fgRgb = parseRgb(fg) + const bgRgb = parseRgb(bg) + if (!fgRgb || !bgRgb) return 0 + const l1 = relativeLuminance(...fgRgb) + const l2 = relativeLuminance(...bgRgb) + const lighter = Math.max(l1, l2) + const darker = Math.min(l1, l2) + return (lighter + 0.05) / (darker + 0.05) +} + +test.describe('Theme contrast', () => { + let userDataDir: string + + test.beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-contrast-')) + }) + + test.afterEach(() => { + if (userDataDir && existsSync(userDataDir)) { + try { + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) + } catch { + /* ignore */ + } + } + }) + + for (const theme of LIGHT_THEMES) { + test(`E2E-CONTRAST: ${theme} theme has readable form controls`, async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await openSettings(page) + + await page.getByTestId('settings-theme').selectOption(theme) + await expect(page.locator('html')).toHaveAttribute('data-theme', theme) + + await expect(page.getByRole('heading', { name: '系统设置' })).toBeVisible() + await expect(page.locator('.setting-item').first()).toBeVisible() + + const penNameOk = await page.evaluate(() => { + const el = document.querySelector('[data-testid="settings-pen-name"]') as HTMLElement | null + if (!el) return false + const cs = getComputedStyle(el) + const fg = cs.color + const bg = cs.backgroundColor + if (fg === 'rgba(0, 0, 0, 0)' || bg === 'rgba(0, 0, 0, 0)') return false + if (fg === bg) return false + return true + }) + expect(penNameOk).toBe(true) + + const ratio = await page.evaluate(() => { + const el = document.querySelector('[data-testid="settings-pen-name"]') as HTMLElement + const cs = getComputedStyle(el) + return { fg: cs.color, bg: cs.backgroundColor } + }) + expect(contrastRatio(ratio.fg, ratio.bg)).toBeGreaterThanOrEqual(4.5) + + await app.close() + }) + } + + test('E2E-CONTRAST: light themes show visible headings on main page', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await openSettings(page) + + for (const theme of LIGHT_THEMES) { + await page.getByTestId('settings-theme').selectOption(theme) + await page.locator('.logo-area').click() + await expect(page.locator('#settings-page')).toBeHidden() + + const headingVisible = await page.evaluate(() => { + const selectors = ['h1', 'h2', 'button', '.setting-item'] + for (const sel of selectors) { + const el = document.querySelector(`#app ${sel}`) as HTMLElement | null + if (!el) continue + const cs = getComputedStyle(el) + if (cs.visibility === 'hidden' || cs.display === 'none') continue + const fg = cs.color + if (fg === 'rgba(0, 0, 0, 0)') continue + return true + } + return false + }) + expect(headingVisible).toBe(true) + + await page.getByRole('button', { name: '⚙ 系统设置' }).click() + await expect(page.locator('#settings-page')).toBeVisible() + } + + await app.close() + }) +}) diff --git a/package-lock.json b/package-lock.json index b378dbc..291b3b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@tiptap/extension-underline": "^3.27.1", "@tiptap/react": "^3.27.1", "@tiptap/starter-kit": "^3.27.1", + "diff-match-patch": "^1.0.5", "electron": "^36.9.0", "i18next": "^24.2.3", "jotai": "^2.12.1", @@ -29,6 +30,7 @@ }, "devDependencies": { "@playwright/test": "^1.51.0", + "@types/diff-match-patch": "^1.0.36", "@types/node": "^22.13.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", @@ -46,7 +48,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==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", @@ -61,7 +63,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -71,7 +73,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -102,7 +104,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -112,7 +114,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.7", @@ -129,7 +131,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==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.29.7", @@ -146,7 +148,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -156,7 +158,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -166,7 +168,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==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.29.7", @@ -180,7 +182,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==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.29.7", @@ -208,7 +210,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==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -218,7 +220,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==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -228,7 +230,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==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -238,7 +240,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/template": "^7.29.7", @@ -252,7 +254,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==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -325,7 +327,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==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -340,7 +342,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -359,7 +361,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -1196,7 +1198,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==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1207,7 +1209,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1218,7 +1220,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==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1228,14 +1230,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, + "devOptional": 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==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2973,6 +2975,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/diff-match-patch": { + "version": "1.0.36", + "resolved": "https://registry.npmmirror.com/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz", + "integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3037,14 +3046,12 @@ "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": "*", @@ -3055,7 +3062,6 @@ "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" @@ -3442,6 +3448,142 @@ "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", @@ -3577,7 +3719,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==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -3640,7 +3782,7 @@ "version": "4.28.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -3845,7 +3987,7 @@ "version": "1.0.30001800", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -4093,6 +4235,23 @@ "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", @@ -4187,7 +4346,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==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/core-util-is": { @@ -4195,8 +4354,7 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/crc": { "version": "3.8.0", @@ -4209,6 +4367,35 @@ "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", @@ -4228,7 +4415,6 @@ "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/debug": { @@ -4383,6 +4569,12 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "license": "Apache-2.0" + }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -4581,6 +4773,20 @@ "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", @@ -4601,7 +4807,7 @@ "version": "1.5.387", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/electron-vite": { @@ -4783,7 +4989,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -4997,6 +5203,14 @@ "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", @@ -5082,7 +5296,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==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -5654,6 +5868,14 @@ "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", @@ -5770,7 +5992,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -5803,7 +6025,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -5841,6 +6063,56 @@ "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", @@ -5854,6 +6126,46 @@ "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", @@ -5903,7 +6215,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -6376,7 +6688,7 @@ "version": "2.0.50", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -6398,6 +6710,17 @@ "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", @@ -6636,7 +6959,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/picomatch": { @@ -6728,6 +7051,14 @@ "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", @@ -7087,6 +7418,50 @@ "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", @@ -7723,6 +8098,24 @@ "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", @@ -7865,7 +8258,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -7921,7 +8314,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==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -8359,7 +8752,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/yargs": { @@ -8414,6 +8807,102 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/zip-stream/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/zip-stream/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/zip-stream/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/zustand": { "version": "5.0.14", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", diff --git a/package.json b/package.json index 37f4d75..c087994 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bilin", - "version": "0.1.0", + "version": "0.2.0", "description": "笔临 - 长篇创作智能协作平台", "main": "./out/main/index.js", "type": "module", @@ -28,6 +28,7 @@ "@tiptap/extension-underline": "^3.27.1", "@tiptap/react": "^3.27.1", "@tiptap/starter-kit": "^3.27.1", + "diff-match-patch": "^1.0.5", "electron": "^36.9.0", "i18next": "^24.2.3", "jotai": "^2.12.1", @@ -39,6 +40,7 @@ }, "devDependencies": { "@playwright/test": "^1.51.0", + "@types/diff-match-patch": "^1.0.36", "@types/node": "^22.13.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 5d37222..cb41ac4 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -68,6 +68,7 @@ "toast.bookCreated": "Book created", "toast.saveFailed": "Save failed, please retry", "toast.enterBookName": "Please enter a book title", + "toast.noBookOpen": "No book open", "shortcuts.saveChapter": "Save chapter", "shortcuts.newChapter": "New chapter", "shortcuts.focusMode": "Focus mode", @@ -83,5 +84,60 @@ "shortcuts.recordHint": "Press a new key combination (Esc to cancel)", "shortcuts.recording": "Recording…", "shortcuts.conflict": "This shortcut is already assigned", - "shortcuts.registerFailed": "Failed to register shortcut, may conflict with another app" + "shortcuts.registerFailed": "Failed to register shortcut, may conflict with another app", + "outline.new": "New Outline", + "outline.newItem": "New outline node", + "outline.empty": "No outline yet. Click below to create one.", + "setting.new": "New Setting", + "setting.empty": "No settings yet", + "setting.typeLabel": "Type", + "setting.nameLabel": "Name", + "setting.type.character": "Character", + "setting.type.location": "Location", + "setting.type.item": "Item", + "setting.type.skill": "Skill", + "setting.type.faction": "Faction", + "setting.type.custom": "Custom", + "inspiration.new": "New Idea", + "inspiration.newItem": "New idea", + "inspiration.empty": "No ideas yet", + "inspiration.untitled": "Untitled", + "inspiration.noContent": "(empty)", + "outline.filterAll": "All", + "outline.filterUncovered": "Uncovered", + "outline.filterCovered": "Covered", + "outline.compare": "Compare", + "outline.compareTitle": "Outline vs Chapter", + "outline.exitCompare": "Exit compare", + "outline.emptyFilter": "No matching items", + "outline.notWritten": "Not written yet", + "outline.noContent": "No content", + "outline.expected": "Expected", + "outline.actual": "Actual", + "outline.deviation": "Deviation", + "outline.jumpEditor": "Open in editor", + "reference.title": "Reference", + "reference.search": "Search references…", + "reference.tab.setting": "Settings", + "reference.tab.outline": "Outline", + "reference.tab.inspiration": "Ideas", + "search.title": "Global Search", + "search.placeholder": "Search…", + "search.regex": "Regex", + "search.caseSensitive": "Case sensitive", + "version.title": "Version History", + "version.empty": "No snapshots", + "version.create": "Create snapshot", + "version.restore": "Restore", + "version.manual": "Manual snapshot", + "version.selectHint": "Select a version to compare", + "version.restoreConfirm": "Restore this version? Current content will be overwritten.", + "landmark.title": "Landmarks", + "landmark.empty": "No landmarks", + "landmark.insert": "Insert landmark", + "landmark.prompt": "Landmark label", + "landmark.created": "Landmark inserted", + "landmark.chapterOnly": "Insert landmarks in a chapter", + "wordfreq.hint": "Top 100 words in current scope", + "wordfreq.aiNaming": "AI naming suggestions" } diff --git a/public/locales/zh-CN/translation.json b/public/locales/zh-CN/translation.json index 8fa7f21..4ed0737 100644 --- a/public/locales/zh-CN/translation.json +++ b/public/locales/zh-CN/translation.json @@ -68,6 +68,7 @@ "toast.bookCreated": "书籍已创建", "toast.saveFailed": "保存失败,请重试", "toast.enterBookName": "请输入书名", + "toast.noBookOpen": "未打开书籍", "shortcuts.saveChapter": "保存章节", "shortcuts.newChapter": "新建章节", "shortcuts.focusMode": "专注模式", @@ -83,5 +84,60 @@ "shortcuts.recordHint": "请按下新的快捷键组合(Esc 取消)", "shortcuts.recording": "录制中…", "shortcuts.conflict": "该快捷键已被其他功能占用", - "shortcuts.registerFailed": "快捷键注册失败,可能与其他应用冲突" + "shortcuts.registerFailed": "快捷键注册失败,可能与其他应用冲突", + "outline.new": "新建大纲", + "outline.newItem": "新大纲节点", + "outline.empty": "暂无大纲,点击下方新建", + "setting.new": "新建设定", + "setting.empty": "暂无设定", + "setting.typeLabel": "类型", + "setting.nameLabel": "名称", + "setting.type.character": "角色", + "setting.type.location": "地点", + "setting.type.item": "物品", + "setting.type.skill": "技能", + "setting.type.faction": "势力", + "setting.type.custom": "自定义", + "inspiration.new": "新建灵感", + "inspiration.newItem": "新灵感", + "inspiration.empty": "暂无灵感", + "inspiration.untitled": "未命名", + "inspiration.noContent": "(无内容)", + "outline.filterAll": "全部", + "outline.filterUncovered": "未覆盖", + "outline.filterCovered": "已覆盖", + "outline.compare": "对照", + "outline.compareTitle": "大纲-正文对照", + "outline.exitCompare": "退出对照", + "outline.emptyFilter": "无匹配条目", + "outline.notWritten": "尚未写作", + "outline.noContent": "暂无内容", + "outline.expected": "预期", + "outline.actual": "实际", + "outline.deviation": "偏差", + "outline.jumpEditor": "跳转编辑器", + "reference.title": "引用面板", + "reference.search": "搜索引用…", + "reference.tab.setting": "设定", + "reference.tab.outline": "大纲", + "reference.tab.inspiration": "灵感", + "search.title": "全局搜索", + "search.placeholder": "输入搜索词…", + "search.regex": "正则", + "search.caseSensitive": "区分大小写", + "version.title": "版本历史", + "version.empty": "暂无快照", + "version.create": "创建快照", + "version.restore": "回滚", + "version.manual": "手动快照", + "version.selectHint": "选择版本查看差异", + "version.restoreConfirm": "确定回滚到该版本?当前内容将被覆盖。", + "landmark.title": "写作地标", + "landmark.empty": "暂无地标", + "landmark.insert": "插入地标", + "landmark.prompt": "地标说明", + "landmark.created": "地标已插入", + "landmark.chapterOnly": "请在章节中插入地标", + "wordfreq.hint": "当前范围词频 Top 100", + "wordfreq.aiNaming": "AI 命名建议" } diff --git a/src/main/db/migrate.ts b/src/main/db/migrate.ts index 6dba0f3..9836745 100644 --- a/src/main/db/migrate.ts +++ b/src/main/db/migrate.ts @@ -1,7 +1,25 @@ import type { SqliteDb } from './types' import schemaV1 from './schema-v1.sql?raw' +import schemaV2 from './schema-v2.sql?raw' -const CURRENT_VERSION = 1 +const CURRENT_VERSION = 2 + +function tryAlter(db: SqliteDb, sql: string): void { + try { + db.exec(sql) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (!msg.includes('duplicate column')) throw err + } +} + +function applyV2(db: SqliteDb): void { + tryAlter(db, "ALTER TABLE chapters ADD COLUMN publish_status TEXT NOT NULL DEFAULT 'draft'") + tryAlter(db, 'ALTER TABLE chapters ADD COLUMN pov_character_id TEXT DEFAULT NULL') + tryAlter(db, "ALTER TABLE chapters ADD COLUMN summary TEXT DEFAULT ''") + tryAlter(db, 'ALTER TABLE chapters ADD COLUMN story_time TEXT DEFAULT NULL') + db.exec(schemaV2) +} export function migrate(db: SqliteDb): void { db.exec(`CREATE TABLE IF NOT EXISTS schema_version ( @@ -11,12 +29,17 @@ export function migrate(db: SqliteDb): void { )`) const row = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number | null } - const current = row?.v ?? 0 + let current = row?.v ?? 0 if (current >= CURRENT_VERSION) return - db.exec(schemaV1) - db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run( - CURRENT_VERSION, - 'initial schema' - ) + if (current < 1) { + db.exec(schemaV1) + db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(1, 'initial schema') + current = 1 + } + + if (current < 2) { + applyV2(db) + db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(2, 'P2 schema') + } } diff --git a/src/main/db/repositories/bookmark.repo.ts b/src/main/db/repositories/bookmark.repo.ts new file mode 100644 index 0000000..be9feaf --- /dev/null +++ b/src/main/db/repositories/bookmark.repo.ts @@ -0,0 +1,92 @@ +import { randomUUID } from 'crypto' +import type { Bookmark, LandmarkType } from '../../../shared/types' +import type { SqliteDb } from '../types' + +function mapRow(row: Record): Bookmark { + return { + id: row.id as string, + chapterId: row.chapter_id as string, + position: row.position as number, + label: row.label as string, + landmarkType: row.landmark_type as LandmarkType, + resolved: Boolean(row.resolved), + createdAt: row.created_at as string + } +} + +export class BookmarkRepository { + constructor(private db: SqliteDb) {} + + create( + chapterId: string, + position: number, + label: string, + landmarkType: LandmarkType = 'todo' + ): Bookmark { + const id = randomUUID() + this.db + .prepare( + `INSERT INTO bookmarks (id, chapter_id, position, label, landmark_type) + VALUES (?, ?, ?, ?, ?)` + ) + .run(id, chapterId, position, label, landmarkType) + return this.get(id)! + } + + get(id: string): Bookmark | null { + const row = this.db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(id) as + | Record + | undefined + return row ? mapRow(row) : null + } + + list(chapterId?: string, resolved?: boolean): Bookmark[] { + let sql = 'SELECT * FROM bookmarks WHERE 1=1' + const params: unknown[] = [] + if (chapterId !== undefined) { + sql += ' AND chapter_id = ?' + params.push(chapterId) + } + if (resolved !== undefined) { + sql += ' AND resolved = ?' + params.push(resolved ? 1 : 0) + } + sql += ' ORDER BY created_at DESC' + return (this.db.prepare(sql).all(...params) as Record[]).map(mapRow) + } + + listByChapter(chapterId: string): Bookmark[] { + return this.list(chapterId) + } + + update( + id: string, + patch: Partial<{ label: string; position: number; landmarkType: LandmarkType }> + ): Bookmark { + const existing = this.get(id) + if (!existing) throw new Error('Bookmark not found') + + this.db + .prepare( + `UPDATE bookmarks SET label = ?, position = ?, landmark_type = ? WHERE id = ?` + ) + .run( + patch.label ?? existing.label, + patch.position ?? existing.position, + patch.landmarkType ?? existing.landmarkType, + id + ) + return this.get(id)! + } + + resolve(id: string, resolved: boolean): Bookmark { + const existing = this.get(id) + if (!existing) throw new Error('Bookmark not found') + this.db.prepare('UPDATE bookmarks SET resolved = ? WHERE id = ?').run(resolved ? 1 : 0, id) + return this.get(id)! + } + + delete(id: string): void { + this.db.prepare('DELETE FROM bookmarks WHERE id = ?').run(id) + } +} diff --git a/src/main/db/repositories/inspiration.repo.ts b/src/main/db/repositories/inspiration.repo.ts new file mode 100644 index 0000000..0933aee --- /dev/null +++ b/src/main/db/repositories/inspiration.repo.ts @@ -0,0 +1,91 @@ +import { randomUUID } from 'crypto' +import type { InspirationEntry, SettingType } from '../../../shared/types' +import type { SqliteDb } from '../types' +import { stripHtml } from '../../services/word-count' +import type { OutlineRepository } from './outline.repo' +import type { SettingRepository } from './setting.repo' + +function mapRow(row: Record): InspirationEntry { + let tags: string[] = [] + try { + tags = JSON.parse((row.tags as string) || '[]') as string[] + } catch { + tags = [] + } + return { + id: row.id as string, + title: row.title as string, + content: row.content as string, + tags, + createdAt: row.created_at as string, + updatedAt: row.updated_at as string + } +} + +export class InspirationRepository { + constructor(private db: SqliteDb) {} + + create(title = '', content = '', tags: string[] = []): InspirationEntry { + const id = randomUUID() + this.db + .prepare(`INSERT INTO inspiration (id, title, content, tags) VALUES (?, ?, ?, ?)`) + .run(id, title, content, JSON.stringify(tags)) + return this.get(id)! + } + + get(id: string): InspirationEntry | null { + const row = this.db.prepare('SELECT * FROM inspiration WHERE id = ?').get(id) as + | Record + | undefined + return row ? mapRow(row) : null + } + + list(): InspirationEntry[] { + return ( + this.db.prepare('SELECT * FROM inspiration ORDER BY updated_at DESC').all() as Record[] + ).map(mapRow) + } + + update( + id: string, + patch: Partial<{ title: string; content: string; tags: string[] }> + ): InspirationEntry { + const existing = this.get(id) + if (!existing) throw new Error('Inspiration not found') + + this.db + .prepare( + `UPDATE inspiration SET title = ?, content = ?, tags = ?, updated_at = datetime('now') WHERE id = ?` + ) + .run( + patch.title ?? existing.title, + patch.content ?? existing.content, + JSON.stringify(patch.tags ?? existing.tags), + id + ) + return this.get(id)! + } + + delete(id: string): void { + this.db.prepare('DELETE FROM inspiration WHERE id = ?').run(id) + } + + convertToOutline(id: string, outlineRepo: OutlineRepository) { + const item = this.get(id) + if (!item) throw new Error('Inspiration not found') + + const outline = outlineRepo.create(null, item.title || '未命名灵感', outlineRepo.nextSortOrder(null)) + outlineRepo.update(outline.id, { description: stripHtml(item.content) }) + this.delete(id) + return outlineRepo.get(outline.id)! + } + + convertToSetting(id: string, type: SettingType, settingRepo: SettingRepository) { + const item = this.get(id) + if (!item) throw new Error('Inspiration not found') + + const setting = settingRepo.create(type, item.title || '未命名设定', stripHtml(item.content)) + this.delete(id) + return setting + } +} diff --git a/src/main/db/repositories/outline.repo.ts b/src/main/db/repositories/outline.repo.ts new file mode 100644 index 0000000..3399e3f --- /dev/null +++ b/src/main/db/repositories/outline.repo.ts @@ -0,0 +1,137 @@ +import { randomUUID } from 'crypto' +import type { OutlineItem, OutlineStatus } from '../../../shared/types' +import type { SqliteDb } from '../types' + +function mapRow(row: Record): OutlineItem { + return { + id: row.id as string, + parentId: (row.parent_id as string | null) ?? null, + title: row.title as string, + description: (row.description as string) ?? '', + status: row.status as OutlineStatus, + expectedWordCount: (row.expected_word_count as number | null) ?? null, + chapterId: (row.chapter_id as string | null) ?? null, + sortOrder: row.sort_order as number, + createdAt: row.created_at as string, + updatedAt: row.updated_at as string + } +} + +function buildTree(items: OutlineItem[]): OutlineItem[] { + const byParent = new Map() + for (const item of items) { + const key = item.parentId + if (!byParent.has(key)) byParent.set(key, []) + byParent.get(key)!.push({ ...item, children: [] }) + } + const attach = (parentId: string | null): OutlineItem[] => { + const nodes = byParent.get(parentId) ?? [] + nodes.sort((a, b) => a.sortOrder - b.sortOrder) + for (const node of nodes) { + node.children = attach(node.id) + } + return nodes + } + return attach(null) +} + +export class OutlineRepository { + constructor(private db: SqliteDb) {} + + create(parentId: string | null, title: string, sortOrder: number): OutlineItem { + const id = randomUUID() + this.db + .prepare( + `INSERT INTO outline_items (id, parent_id, title, sort_order) + VALUES (?, ?, ?, ?)` + ) + .run(id, parentId, title, sortOrder) + return this.get(id)! + } + + get(id: string): OutlineItem | null { + const row = this.db.prepare('SELECT * FROM outline_items WHERE id = ?').get(id) as + | Record + | undefined + return row ? mapRow(row) : null + } + + listFlat(): OutlineItem[] { + return ( + this.db.prepare('SELECT * FROM outline_items ORDER BY sort_order').all() as Record[] + ).map(mapRow) + } + + listTree(): OutlineItem[] { + return buildTree(this.listFlat()) + } + + update( + id: string, + patch: Partial<{ + title: string + description: string + status: OutlineStatus + expectedWordCount: number | null + chapterId: string | null + sortOrder: number + }> + ): OutlineItem { + const existing = this.get(id) + if (!existing) throw new Error('Outline item not found') + + this.db + .prepare( + `UPDATE outline_items SET + title = ?, description = ?, status = ?, + expected_word_count = ?, chapter_id = ?, sort_order = ?, + updated_at = datetime('now') + WHERE id = ?` + ) + .run( + patch.title ?? existing.title, + patch.description ?? existing.description, + patch.status ?? existing.status, + patch.expectedWordCount !== undefined ? patch.expectedWordCount : existing.expectedWordCount, + patch.chapterId !== undefined ? patch.chapterId : existing.chapterId, + patch.sortOrder ?? existing.sortOrder, + id + ) + return this.get(id)! + } + + delete(id: string): void { + this.db.prepare('DELETE FROM outline_items WHERE id = ?').run(id) + } + + move(id: string, parentId: string | null, sortOrder: number): OutlineItem { + const existing = this.get(id) + if (!existing) throw new Error('Outline item not found') + + this.db + .prepare( + `UPDATE outline_items SET parent_id = ?, sort_order = ?, updated_at = datetime('now') WHERE id = ?` + ) + .run(parentId, sortOrder, id) + return this.get(id)! + } + + listUncovered(): OutlineItem[] { + return ( + this.db + .prepare('SELECT * FROM outline_items WHERE chapter_id IS NULL ORDER BY sort_order') + .all() as Record[] + ).map(mapRow) + } + + nextSortOrder(parentId: string | null): number { + const row = parentId + ? (this.db + .prepare('SELECT MAX(sort_order) AS maxOrder FROM outline_items WHERE parent_id = ?') + .get(parentId) as { maxOrder: number | null }) + : (this.db + .prepare('SELECT MAX(sort_order) AS maxOrder FROM outline_items WHERE parent_id IS NULL') + .get() as { maxOrder: number | null }) + return (row?.maxOrder ?? -1) + 1 + } +} diff --git a/src/main/db/repositories/setting.repo.ts b/src/main/db/repositories/setting.repo.ts new file mode 100644 index 0000000..12ceea2 --- /dev/null +++ b/src/main/db/repositories/setting.repo.ts @@ -0,0 +1,103 @@ +import { randomUUID } from 'crypto' +import type { SettingEntry, SettingType } from '../../../shared/types' +import type { SqliteDb } from '../types' + +function mapRow(row: Record, chapterIds: string[]): SettingEntry { + let properties: Record = {} + try { + properties = JSON.parse((row.properties as string) || '{}') as Record + } catch { + properties = {} + } + return { + id: row.id as string, + type: row.type as SettingType, + name: row.name as string, + description: (row.description as string) ?? '', + properties, + chapterIds, + createdAt: row.created_at as string, + updatedAt: row.updated_at as string + } +} + +export class SettingRepository { + constructor(private db: SqliteDb) {} + + private loadChapterIds(settingId: string): string[] { + return ( + this.db + .prepare('SELECT chapter_id FROM setting_chapter_refs WHERE setting_id = ?') + .all(settingId) as { chapter_id: string }[] + ).map((r) => r.chapter_id) + } + + create(type: SettingType, name: string, description = '', properties: Record = {}): SettingEntry { + const id = randomUUID() + this.db + .prepare( + `INSERT INTO settings (id, type, name, description, properties) + VALUES (?, ?, ?, ?, ?)` + ) + .run(id, type, name, description, JSON.stringify(properties)) + return this.get(id)! + } + + get(id: string): SettingEntry | null { + const row = this.db.prepare('SELECT * FROM settings WHERE id = ?').get(id) as + | Record + | undefined + return row ? mapRow(row, this.loadChapterIds(id)) : null + } + + list(type?: SettingType): SettingEntry[] { + const rows = type + ? (this.db.prepare('SELECT * FROM settings WHERE type = ? ORDER BY name').all(type) as Record< + string, + unknown + >[]) + : (this.db.prepare('SELECT * FROM settings ORDER BY type, name').all() as Record[]) + return rows.map((row) => mapRow(row, this.loadChapterIds(row.id as string))) + } + + update( + id: string, + patch: Partial<{ name: string; description: string; type: SettingType; properties: Record }> + ): SettingEntry { + const existing = this.get(id) + if (!existing) throw new Error('Setting not found') + + this.db + .prepare( + `UPDATE settings SET + type = ?, name = ?, description = ?, properties = ?, + updated_at = datetime('now') + WHERE id = ?` + ) + .run( + patch.type ?? existing.type, + patch.name ?? existing.name, + patch.description ?? existing.description, + JSON.stringify(patch.properties ?? existing.properties), + id + ) + return this.get(id)! + } + + delete(id: string): void { + this.db.prepare('DELETE FROM settings WHERE id = ?').run(id) + } + + syncChapterRefs(settingId: string, chapterIds: string[]): SettingEntry { + if (!this.get(settingId)) throw new Error('Setting not found') + + this.db.prepare('DELETE FROM setting_chapter_refs WHERE setting_id = ?').run(settingId) + const insert = this.db.prepare( + 'INSERT INTO setting_chapter_refs (setting_id, chapter_id) VALUES (?, ?)' + ) + for (const chapterId of chapterIds) { + insert.run(settingId, chapterId) + } + return this.get(settingId)! + } +} diff --git a/src/main/db/repositories/snapshot.repo.ts b/src/main/db/repositories/snapshot.repo.ts new file mode 100644 index 0000000..e29aab2 --- /dev/null +++ b/src/main/db/repositories/snapshot.repo.ts @@ -0,0 +1,81 @@ +import { randomUUID } from 'crypto' +import type { Snapshot, SnapshotType } from '../../../shared/types' +import type { SqliteDb } from '../types' + +function mapRow(row: Record): Snapshot { + return { + id: row.id as string, + chapterId: row.chapter_id as string, + type: row.type as SnapshotType, + name: (row.name as string) ?? '', + content: row.content as string, + createdAt: row.created_at as string + } +} + +export class SnapshotRepository { + constructor(private db: SqliteDb) {} + + create(chapterId: string, type: SnapshotType, content: string, name = ''): Snapshot { + const id = randomUUID() + this.db + .prepare(`INSERT INTO snapshots (id, chapter_id, type, name, content) VALUES (?, ?, ?, ?, ?)`) + .run(id, chapterId, type, name, content) + return this.get(id)! + } + + get(id: string): Snapshot | null { + const row = this.db.prepare('SELECT * FROM snapshots WHERE id = ?').get(id) as + | Record + | undefined + return row ? mapRow(row) : null + } + + list(chapterId: string): Snapshot[] { + return ( + this.db + .prepare('SELECT * FROM snapshots WHERE chapter_id = ? ORDER BY created_at DESC') + .all(chapterId) as Record[] + ).map(mapRow) + } + + delete(id: string): void { + this.db.prepare('DELETE FROM snapshots WHERE id = ?').run(id) + } + + pruneAuto(chapterId: string, max: number): void { + const autos = ( + this.db + .prepare( + `SELECT id FROM snapshots WHERE chapter_id = ? AND type = 'auto' ORDER BY created_at ASC` + ) + .all(chapterId) as { id: string }[] + ) + const excess = autos.length - max + if (excess <= 0) return + for (let i = 0; i < excess; i++) { + this.delete(autos[i].id) + } + } + + prunePersist(chapterId: string, max: number): void { + const items = ( + this.db + .prepare( + `SELECT id FROM snapshots WHERE chapter_id = ? AND type = 'persist' ORDER BY created_at ASC` + ) + .all(chapterId) as { id: string }[] + ) + const excess = items.length - max + if (excess <= 0) return + for (let i = 0; i < excess; i++) { + this.delete(items[i].id) + } + } + + restore(snapshotId: string): string { + const snap = this.get(snapshotId) + if (!snap) throw new Error('Snapshot not found') + return snap.content + } +} diff --git a/src/main/db/schema-v2.sql b/src/main/db/schema-v2.sql new file mode 100644 index 0000000..11787dc --- /dev/null +++ b/src/main/db/schema-v2.sql @@ -0,0 +1,70 @@ +CREATE TABLE IF NOT EXISTS outline_items ( + id TEXT PRIMARY KEY, + parent_id TEXT, + title TEXT NOT NULL, + description TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + expected_word_count INTEGER DEFAULT NULL, + chapter_id TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (parent_id) REFERENCES outline_items(id) ON DELETE CASCADE, + FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS settings ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT DEFAULT '', + properties TEXT DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS setting_chapter_refs ( + setting_id TEXT NOT NULL, + chapter_id TEXT NOT NULL, + PRIMARY KEY (setting_id, chapter_id), + FOREIGN KEY (setting_id) REFERENCES settings(id) ON DELETE CASCADE, + FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS inspiration ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '', + tags TEXT DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS snapshots ( + id TEXT PRIMARY KEY, + chapter_id TEXT NOT NULL, + type TEXT NOT NULL, + name TEXT DEFAULT '', + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS bookmarks ( + id TEXT PRIMARY KEY, + chapter_id TEXT NOT NULL, + position INTEGER NOT NULL, + label TEXT NOT NULL, + landmark_type TEXT NOT NULL DEFAULT 'todo', + resolved INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE +); + +CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5( + source_kind, + source_id, + title, + body, + tokenize='unicode61' +); diff --git a/src/main/index.ts b/src/main/index.ts index 1b42d5d..17ea336 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, ipcMain } from 'electron' import { existsSync } from 'fs' import { join } from 'path' import { IPC } from '../shared/ipc-channels' -import { getShortcutManager, registerIpc } from './ipc/register' +import { getShortcutManager, getSnapshotService, registerIpc } from './ipc/register' import { closeAllBookDbs } from './db/connection' if (process.env.BILIN_E2E === '1' && process.env.BILIN_E2E_USER_DATA) { @@ -66,6 +66,7 @@ app.whenReady().then(() => { app.on('window-all-closed', () => { getShortcutManager()?.unregisterAll() + getSnapshotService()?.stopAll() closeAllBookDbs() if (process.platform !== 'darwin') app.quit() }) @@ -74,6 +75,10 @@ app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) +app.on('before-quit', () => { + getSnapshotService()?.stopAll() +}) + app.on('will-quit', () => { getShortcutManager()?.unregisterAll() }) diff --git a/src/main/ipc/handlers/bookmark.handler.ts b/src/main/ipc/handlers/bookmark.handler.ts new file mode 100644 index 0000000..534e5fc --- /dev/null +++ b/src/main/ipc/handlers/bookmark.handler.ts @@ -0,0 +1,81 @@ +import { ipcMain } from 'electron' +import { IPC } from '../../../shared/ipc-channels' +import type { LandmarkType } from '../../../shared/types' +import { BookRegistryService } from '../../services/book-registry' +import { ftsSync } from '../../services/fts-sync.service' +import { wrap } from '../result' + +export function registerBookmarkHandlers(registry: BookRegistryService): void { + ipcMain.handle( + IPC.BOOKMARK_LIST, + ( + _e, + { + bookId, + chapterId, + resolved + }: { bookId: string; chapterId?: string; resolved?: boolean } + ) => wrap(() => registry.getBookmarkRepo(bookId).list(chapterId, resolved)) + ) + + ipcMain.handle( + IPC.BOOKMARK_CREATE, + ( + _e, + { + bookId, + chapterId, + position, + label, + landmarkType + }: { + bookId: string + chapterId: string + position: number + label: string + landmarkType?: LandmarkType + } + ) => + wrap(() => { + const item = registry + .getBookmarkRepo(bookId) + .create(chapterId, position, label, landmarkType) + ftsSync.upsert(registry.getDb(bookId), 'bookmark', item.id, item.label, item.label) + return item + }) + ) + + ipcMain.handle( + IPC.BOOKMARK_UPDATE, + ( + _e, + { + bookId, + id, + patch + }: { + bookId: string + id: string + patch: Partial<{ label: string; position: number; landmarkType: LandmarkType }> + } + ) => + wrap(() => { + const item = registry.getBookmarkRepo(bookId).update(id, patch) + ftsSync.upsert(registry.getDb(bookId), 'bookmark', item.id, item.label, item.label) + return item + }) + ) + + ipcMain.handle( + IPC.BOOKMARK_RESOLVE, + (_e, { bookId, id, resolved }: { bookId: string; id: string; resolved: boolean }) => + wrap(() => registry.getBookmarkRepo(bookId).resolve(id, resolved)) + ) + + ipcMain.handle(IPC.BOOKMARK_DELETE, (_e, { bookId, id }: { bookId: string; id: string }) => + wrap(() => { + registry.getBookmarkRepo(bookId).delete(id) + ftsSync.remove(registry.getDb(bookId), 'bookmark', id) + }) + ) +} diff --git a/src/main/ipc/handlers/chapter.handler.ts b/src/main/ipc/handlers/chapter.handler.ts index d54e359..6ecbd9a 100644 --- a/src/main/ipc/handlers/chapter.handler.ts +++ b/src/main/ipc/handlers/chapter.handler.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron' import { IPC } from '../../../shared/ipc-channels' import type { ChapterStatus } from '../../../shared/types' import { BookRegistryService } from '../../services/book-registry' +import { ftsSync } from '../../services/fts-sync.service' import { wrap } from '../result' export function registerChapterHandlers(registry: BookRegistryService): void { @@ -40,7 +41,9 @@ export function registerChapterHandlers(registry: BookRegistryService): void { wrap(() => { const repo = registry.getChapterRepo(bookId) const sortOrder = repo.nextSortOrder(volumeId) - return repo.create(volumeId, title, sortOrder) + const chapter = repo.create(volumeId, title, sortOrder) + ftsSync.upsert(registry.getDb(bookId), 'chapter', chapter.id, chapter.title, chapter.content) + return chapter }) ) @@ -81,6 +84,13 @@ export function registerChapterHandlers(registry: BookRegistryService): void { status, cursorOffset }) + ftsSync.upsert( + registry.getDb(bookId), + 'chapter', + chapter.id, + chapter.title, + chapter.content + ) registry.updateMeta(bookId, { lastChapterId: chapterId }) return chapter }) @@ -91,6 +101,7 @@ export function registerChapterHandlers(registry: BookRegistryService): void { (_event, { bookId, chapterId }: { bookId: string; chapterId: string }) => wrap(() => { registry.getChapterRepo(bookId).delete(chapterId) + ftsSync.remove(registry.getDb(bookId), 'chapter', chapterId) }) ) } diff --git a/src/main/ipc/handlers/inspiration.handler.ts b/src/main/ipc/handlers/inspiration.handler.ts new file mode 100644 index 0000000..abe4b0a --- /dev/null +++ b/src/main/ipc/handlers/inspiration.handler.ts @@ -0,0 +1,86 @@ +import { ipcMain } from 'electron' +import { IPC } from '../../../shared/ipc-channels' +import type { SettingType } from '../../../shared/types' +import { BookRegistryService } from '../../services/book-registry' +import { ftsSync } from '../../services/fts-sync.service' +import { wrap } from '../result' + +export function registerInspirationHandlers(registry: BookRegistryService): void { + ipcMain.handle(IPC.INSPIRATION_LIST, (_e, { bookId }: { bookId: string }) => + wrap(() => registry.getInspirationRepo(bookId).list()) + ) + + ipcMain.handle( + IPC.INSPIRATION_CREATE, + ( + _e, + { + bookId, + title, + content, + tags + }: { bookId: string; title?: string; content?: string; tags?: string[] } + ) => + wrap(() => { + const item = registry.getInspirationRepo(bookId).create(title, content, tags) + ftsSync.upsert(registry.getDb(bookId), 'inspiration', item.id, item.title, item.content) + return item + }) + ) + + ipcMain.handle( + IPC.INSPIRATION_UPDATE, + ( + _e, + { + bookId, + id, + patch + }: { + bookId: string + id: string + patch: Partial<{ title: string; content: string; tags: string[] }> + } + ) => + wrap(() => { + const item = registry.getInspirationRepo(bookId).update(id, patch) + ftsSync.upsert(registry.getDb(bookId), 'inspiration', item.id, item.title, item.content) + return item + }) + ) + + ipcMain.handle(IPC.INSPIRATION_DELETE, (_e, { bookId, id }: { bookId: string; id: string }) => + wrap(() => { + registry.getInspirationRepo(bookId).delete(id) + ftsSync.remove(registry.getDb(bookId), 'inspiration', id) + }) + ) + + ipcMain.handle( + IPC.INSPIRATION_CONVERT, + ( + _e, + { + bookId, + id, + targetKind, + settingType + }: { bookId: string; id: string; targetKind: 'outline' | 'setting'; settingType?: SettingType } + ) => + wrap(() => { + const repo = registry.getInspirationRepo(bookId) + if (targetKind === 'outline') { + const item = repo.convertToOutline(id, registry.getOutlineRepo(bookId)) + ftsSync.upsert(registry.getDb(bookId), 'outline', item.id, item.title, item.description) + return item + } + const item = repo.convertToSetting( + id, + settingType ?? 'custom', + registry.getSettingRepo(bookId) + ) + ftsSync.upsert(registry.getDb(bookId), 'setting', item.id, item.name, item.description) + return item + }) + ) +} diff --git a/src/main/ipc/handlers/outline.handler.ts b/src/main/ipc/handlers/outline.handler.ts new file mode 100644 index 0000000..0d37b84 --- /dev/null +++ b/src/main/ipc/handlers/outline.handler.ts @@ -0,0 +1,82 @@ +import { ipcMain } from 'electron' +import { IPC } from '../../../shared/ipc-channels' +import type { OutlineStatus } from '../../../shared/types' +import { BookRegistryService } from '../../services/book-registry' +import { ftsSync } from '../../services/fts-sync.service' +import { wrap } from '../result' + +export function registerOutlineHandlers(registry: BookRegistryService): void { + ipcMain.handle(IPC.OUTLINE_LIST, (_e, { bookId }: { bookId: string }) => + wrap(() => registry.getOutlineRepo(bookId).listFlat()) + ) + + ipcMain.handle( + IPC.OUTLINE_CREATE, + ( + _e, + { + bookId, + parentId, + title, + sortOrder + }: { bookId: string; parentId?: string | null; title: string; sortOrder?: number } + ) => + wrap(() => { + const repo = registry.getOutlineRepo(bookId) + const order = sortOrder ?? repo.nextSortOrder(parentId ?? null) + const item = repo.create(parentId ?? null, title, order) + const db = registry.getDb(bookId) + ftsSync.upsert(db, 'outline', item.id, item.title, item.description) + return item + }) + ) + + ipcMain.handle( + IPC.OUTLINE_UPDATE, + ( + _e, + { + bookId, + id, + patch + }: { + bookId: string + id: string + patch: Partial<{ + title: string + description: string + status: OutlineStatus + expectedWordCount: number | null + chapterId: string | null + sortOrder: number + }> + } + ) => + wrap(() => { + const item = registry.getOutlineRepo(bookId).update(id, patch) + const db = registry.getDb(bookId) + ftsSync.upsert(db, 'outline', item.id, item.title, item.description) + return item + }) + ) + + ipcMain.handle(IPC.OUTLINE_DELETE, (_e, { bookId, id }: { bookId: string; id: string }) => + wrap(() => { + registry.getOutlineRepo(bookId).delete(id) + ftsSync.remove(registry.getDb(bookId), 'outline', id) + }) + ) + + ipcMain.handle( + IPC.OUTLINE_MOVE, + ( + _e, + { + bookId, + id, + parentId, + sortOrder + }: { bookId: string; id: string; parentId?: string | null; sortOrder: number } + ) => wrap(() => registry.getOutlineRepo(bookId).move(id, parentId ?? null, sortOrder)) + ) +} diff --git a/src/main/ipc/handlers/search.handler.ts b/src/main/ipc/handlers/search.handler.ts new file mode 100644 index 0000000..f65451f --- /dev/null +++ b/src/main/ipc/handlers/search.handler.ts @@ -0,0 +1,99 @@ +import { ipcMain } from 'electron' +import { IPC } from '../../../shared/ipc-channels' +import type { SearchScope } from '../../../shared/types' +import { BookRegistryService } from '../../services/book-registry' +import { GlobalSettingsService } from '../../services/global-settings' +import { searchService } from '../../services/search.service' +import { wordFreqService } from '../../services/wordfreq.service' +import { wrap } from '../result' + +const MAX_SEARCH_HISTORY = 10 + +export function registerSearchHandlers( + registry: BookRegistryService, + settings: GlobalSettingsService +): void { + ipcMain.handle( + IPC.SEARCH_QUERY, + ( + _e, + { + bookId, + query, + scope, + options + }: { + bookId: string + query: string + scope?: SearchScope + options?: { regex?: boolean; caseSensitive?: boolean; matchWholeWord?: boolean } + } + ) => + wrap(() => + searchService.query(registry.getDb(bookId), { + text: query, + scope, + regex: options?.regex, + caseSensitive: options?.caseSensitive + }) + ) + ) + + ipcMain.handle( + IPC.SEARCH_REPLACE, + ( + _e, + { + bookId, + query, + replacement, + scope, + dryRun, + options + }: { + bookId: string + query: string + replacement: string + scope?: SearchScope + dryRun: boolean + options?: { regex?: boolean; caseSensitive?: boolean } + } + ) => + wrap(() => + searchService.replace(registry.getDb(bookId), { + text: query, + replacement, + dryRun, + regex: options?.regex, + caseSensitive: options?.caseSensitive + }) + ) + ) + + ipcMain.handle(IPC.SEARCH_GET_HISTORY, () => wrap(() => settings.get().searchHistory)) + + ipcMain.handle(IPC.SEARCH_SAVE_HISTORY, (_e, { query }: { query: string }) => + wrap(() => { + const trimmed = query.trim() + if (!trimmed) return settings.get().searchHistory + const current = settings.get().searchHistory.filter((q) => q !== trimmed) + const next = [trimmed, ...current].slice(0, MAX_SEARCH_HISTORY) + settings.update({ searchHistory: next }) + return next + }) + ) + + ipcMain.handle( + IPC.WORDFREQ_ANALYZE, + ( + _e, + { + bookId, + scope + }: { + bookId: string + scope: { kind: 'book' | 'volume' | 'chapter'; chapterId?: string; volumeId?: string } + } + ) => wrap(() => wordFreqService.analyze(registry.getDb(bookId), scope)) + ) +} diff --git a/src/main/ipc/handlers/setting.handler.ts b/src/main/ipc/handlers/setting.handler.ts new file mode 100644 index 0000000..6821478 --- /dev/null +++ b/src/main/ipc/handlers/setting.handler.ts @@ -0,0 +1,58 @@ +import { ipcMain } from 'electron' +import { IPC } from '../../../shared/ipc-channels' +import type { SettingType } from '../../../shared/types' +import { BookRegistryService } from '../../services/book-registry' +import { ftsSync } from '../../services/fts-sync.service' +import { wrap } from '../result' + +export function registerSettingHandlers(registry: BookRegistryService): void { + ipcMain.handle( + IPC.SETTING_LIST, + (_e, { bookId, type }: { bookId: string; type?: SettingType }) => + wrap(() => registry.getSettingRepo(bookId).list(type)) + ) + + ipcMain.handle( + IPC.SETTING_CREATE, + (_e, { bookId, type, name }: { bookId: string; type: SettingType; name: string }) => + wrap(() => { + const item = registry.getSettingRepo(bookId).create(type, name) + ftsSync.upsert(registry.getDb(bookId), 'setting', item.id, item.name, item.description) + return item + }) + ) + + ipcMain.handle( + IPC.SETTING_UPDATE, + ( + _e, + { + bookId, + id, + patch + }: { + bookId: string + id: string + patch: Partial<{ name: string; description: string; type: SettingType; properties: Record }> + } + ) => + wrap(() => { + const item = registry.getSettingRepo(bookId).update(id, patch) + ftsSync.upsert(registry.getDb(bookId), 'setting', item.id, item.name, item.description) + return item + }) + ) + + ipcMain.handle(IPC.SETTING_DELETE, (_e, { bookId, id }: { bookId: string; id: string }) => + wrap(() => { + registry.getSettingRepo(bookId).delete(id) + ftsSync.remove(registry.getDb(bookId), 'setting', id) + }) + ) + + ipcMain.handle( + IPC.SETTING_SYNC_REFS, + (_e, { bookId, id, chapterIds }: { bookId: string; id: string; chapterIds: string[] }) => + wrap(() => registry.getSettingRepo(bookId).syncChapterRefs(id, chapterIds)) + ) +} diff --git a/src/main/ipc/handlers/snapshot.handler.ts b/src/main/ipc/handlers/snapshot.handler.ts new file mode 100644 index 0000000..77aebd7 --- /dev/null +++ b/src/main/ipc/handlers/snapshot.handler.ts @@ -0,0 +1,76 @@ +import { ipcMain } from 'electron' +import { IPC } from '../../../shared/ipc-channels' +import type { SnapshotType } from '../../../shared/types' +import { BookRegistryService } from '../../services/book-registry' +import type { SnapshotService } from '../../services/snapshot.service' +import { wrap } from '../result' + +export function registerSnapshotHandlers( + registry: BookRegistryService, + snapshotService: SnapshotService +): void { ipcMain.handle( + IPC.SNAPSHOT_LIST, + (_e, { bookId, chapterId }: { bookId: string; chapterId: string }) => + wrap(() => registry.getSnapshotRepo(bookId).list(chapterId)) + ) + + ipcMain.handle( + IPC.SNAPSHOT_CREATE, + ( + _e, + { + bookId, + chapterId, + type, + name, + content + }: { bookId: string; chapterId: string; type: SnapshotType; name?: string; content?: string } + ) => + wrap(() => { + const repo = registry.getSnapshotRepo(bookId) + const chapter = registry.getChapterRepo(bookId).get(chapterId) + if (!chapter) throw new Error('Chapter not found') + return repo.create(chapterId, type, content ?? chapter.content, name) + }) + ) + + ipcMain.handle( + IPC.SNAPSHOT_RESTORE, + (_e, { bookId, chapterId, snapshotId }: { bookId: string; chapterId: string; snapshotId: string }) => + wrap(() => { + const content = registry.getSnapshotRepo(bookId).restore(snapshotId) + return registry.getChapterRepo(bookId).update(chapterId, { content }) + }) + ) + + ipcMain.handle( + IPC.SNAPSHOT_DELETE, + (_e, { bookId, snapshotId }: { bookId: string; snapshotId: string }) => + wrap(() => { + registry.getSnapshotRepo(bookId).delete(snapshotId) + }) + ) + + ipcMain.handle( + IPC.SNAPSHOT_START_AUTO, + (_e, { bookId, chapterId }: { bookId: string; chapterId: string }) => + wrap(() => { + const repo = registry.getSnapshotRepo(bookId) + const chapterRepo = registry.getChapterRepo(bookId) + snapshotService.startAutoSave( + bookId, + chapterId, + () => chapterRepo.get(chapterId)?.content ?? '', + repo + ) + }) + ) + + ipcMain.handle( + IPC.SNAPSHOT_STOP_AUTO, + (_e, { bookId, chapterId }: { bookId: string; chapterId: string }) => + wrap(() => { + snapshotService.stopAutoSave(bookId, chapterId) + }) + ) +} \ No newline at end of file diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index 463e8a5..e8dea14 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -2,13 +2,21 @@ import { join } from 'path' import { app } from 'electron' import { GlobalSettingsService } from '../services/global-settings' import { BookRegistryService } from '../services/book-registry' +import { SnapshotService } from '../services/snapshot.service' import { ShortcutManager } from '../shortcuts/manager' import { registerSettingsHandlers } from './handlers/settings.handler' import { registerBookHandlers } from './handlers/book.handler' import { registerChapterHandlers } from './handlers/chapter.handler' import { registerShortcutHandlers } from './handlers/shortcut.handler' +import { registerOutlineHandlers } from './handlers/outline.handler' +import { registerSettingHandlers } from './handlers/setting.handler' +import { registerInspirationHandlers } from './handlers/inspiration.handler' +import { registerSnapshotHandlers } from './handlers/snapshot.handler' +import { registerBookmarkHandlers } from './handlers/bookmark.handler' +import { registerSearchHandlers } from './handlers/search.handler' let shortcutManager: ShortcutManager | null = null +let snapshotService: SnapshotService | null = null export function registerIpc(): { settings: GlobalSettingsService; books: BookRegistryService; shortcuts: ShortcutManager } { const userData = app.getPath('userData') @@ -16,11 +24,18 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg const settings = new GlobalSettingsService(userData) const books = new BookRegistryService(userData) shortcutManager = new ShortcutManager(settings) + snapshotService = new SnapshotService(() => settings.get()) registerSettingsHandlers(settings) registerBookHandlers(books) registerChapterHandlers(books) registerShortcutHandlers(shortcutManager) + registerOutlineHandlers(books) + registerSettingHandlers(books) + registerInspirationHandlers(books) + registerSnapshotHandlers(books, snapshotService!) + registerBookmarkHandlers(books) + registerSearchHandlers(books, settings) return { settings, books, shortcuts: shortcutManager } } @@ -28,3 +43,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg export function getShortcutManager(): ShortcutManager | null { return shortcutManager } + +export function getSnapshotService(): SnapshotService | null { + return snapshotService +} diff --git a/src/main/services/book-registry.ts b/src/main/services/book-registry.ts index fd89dc4..9fde876 100644 --- a/src/main/services/book-registry.ts +++ b/src/main/services/book-registry.ts @@ -4,6 +4,11 @@ import { randomUUID } from 'crypto' import { openBookDb } from '../db/connection' import { VolumeRepository } from '../db/repositories/volume.repo' import { ChapterRepository } from '../db/repositories/chapter.repo' +import { OutlineRepository } from '../db/repositories/outline.repo' +import { SettingRepository } from '../db/repositories/setting.repo' +import { InspirationRepository } from '../db/repositories/inspiration.repo' +import { SnapshotRepository } from '../db/repositories/snapshot.repo' +import { BookmarkRepository } from '../db/repositories/bookmark.repo' import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume } from '../../shared/types' interface RegistryFile { @@ -99,9 +104,19 @@ export class BookRegistryService { const db = openBookDb(join(this.userDataDir, meta.dbPath)) const volumes = new VolumeRepository(db).list() const chapters = new ChapterRepository(db).list() + const outlines = new OutlineRepository(db).listFlat() + const settings = new SettingRepository(db).list() + const inspirations = new InspirationRepository(db).list() const updated = this.updateMeta(bookId, { lastOpenedAt: new Date().toISOString() }) - return { meta: updated, volumes, chapters } + return { + meta: updated, + volumes, + chapters, + outlines, + settings, + inspirations + } } getDb(bookId: string) { @@ -117,6 +132,26 @@ export class BookRegistryService { getChapterRepo(bookId: string): ChapterRepository { return new ChapterRepository(this.getDb(bookId)) } + + getOutlineRepo(bookId: string): OutlineRepository { + return new OutlineRepository(this.getDb(bookId)) + } + + getSettingRepo(bookId: string): SettingRepository { + return new SettingRepository(this.getDb(bookId)) + } + + getInspirationRepo(bookId: string): InspirationRepository { + return new InspirationRepository(this.getDb(bookId)) + } + + getSnapshotRepo(bookId: string): SnapshotRepository { + return new SnapshotRepository(this.getDb(bookId)) + } + + getBookmarkRepo(bookId: string): BookmarkRepository { + return new BookmarkRepository(this.getDb(bookId)) + } } export function aggregateWordCounts(chapters: Chapter[], volumes: Volume[]): { diff --git a/src/main/services/fts-sync.service.ts b/src/main/services/fts-sync.service.ts new file mode 100644 index 0000000..54694d9 --- /dev/null +++ b/src/main/services/fts-sync.service.ts @@ -0,0 +1,68 @@ +import type { SearchSourceKind } from '../../shared/types' +import { stripHtml } from './word-count' +import type { SqliteDb } from '../db/types' + +export class FtsSyncService { + upsert(db: SqliteDb, kind: SearchSourceKind, id: string, title: string, body: string): void { + const plainBody = stripHtml(body) + db.prepare('DELETE FROM search_fts WHERE source_kind = ? AND source_id = ?').run(kind, id) + db.prepare( + 'INSERT INTO search_fts (source_kind, source_id, title, body) VALUES (?, ?, ?, ?)' + ).run(kind, id, title, plainBody) + } + + remove(db: SqliteDb, kind: SearchSourceKind, id: string): void { + db.prepare('DELETE FROM search_fts WHERE source_kind = ? AND source_id = ?').run(kind, id) + } + + rebuildAll(db: SqliteDb): void { + db.exec('DELETE FROM search_fts') + + const chapters = db.prepare('SELECT id, title, content FROM chapters').all() as { + id: string + title: string + content: string + }[] + for (const ch of chapters) { + this.upsert(db, 'chapter', ch.id, ch.title, ch.content) + } + + const outlines = db.prepare('SELECT id, title, description FROM outline_items').all() as { + id: string + title: string + description: string + }[] + for (const o of outlines) { + this.upsert(db, 'outline', o.id, o.title, o.description) + } + + const settings = db.prepare('SELECT id, name, description FROM settings').all() as { + id: string + name: string + description: string + }[] + for (const s of settings) { + this.upsert(db, 'setting', s.id, s.name, s.description) + } + + const inspirations = db.prepare('SELECT id, title, content FROM inspiration').all() as { + id: string + title: string + content: string + }[] + for (const i of inspirations) { + this.upsert(db, 'inspiration', i.id, i.title, i.content) + } + + const bookmarks = db.prepare('SELECT id, label, chapter_id FROM bookmarks').all() as { + id: string + label: string + chapter_id: string + }[] + for (const b of bookmarks) { + this.upsert(db, 'bookmark', b.id, b.label, b.label) + } + } +} + +export const ftsSync = new FtsSyncService() diff --git a/src/main/services/global-settings.ts b/src/main/services/global-settings.ts index d5fd39c..796f264 100644 --- a/src/main/services/global-settings.ts +++ b/src/main/services/global-settings.ts @@ -12,7 +12,11 @@ function defaults(): GlobalSettings { theme: 'default', language: 'zh-CN', dailyWordGoal: 0, - shortcuts: { ...DEFAULT_SHORTCUTS } + shortcuts: { ...DEFAULT_SHORTCUTS }, + snapshotIntervalMs: 300_000, + snapshotMaxAuto: 20, + snapshotMaxPersist: 3, + searchHistory: [] } } diff --git a/src/main/services/search.service.ts b/src/main/services/search.service.ts new file mode 100644 index 0000000..668675a --- /dev/null +++ b/src/main/services/search.service.ts @@ -0,0 +1,159 @@ +import type { SearchResult, SearchSourceKind } from '../../shared/types' +import type { SqliteDb } from '../db/types' + +export interface SearchQueryParams { + text: string + scope?: 'all' | 'volume' | 'chapter' + regex?: boolean + caseSensitive?: boolean + chapterId?: string +} + +export interface SearchReplaceParams { + text: string + replacement: string + dryRun: boolean + regex?: boolean + caseSensitive?: boolean +} + +function makeSnippet(body: string, query: string, caseSensitive: boolean): string { + const hay = caseSensitive ? body : body.toLowerCase() + const needle = caseSensitive ? query : query.toLowerCase() + const idx = hay.indexOf(needle) + if (idx === -1) return body.slice(0, 40) + const start = Math.max(0, idx - 20) + const end = Math.min(body.length, idx + query.length + 20) + return body.slice(start, end) +} + +function escapeFtsTerm(term: string): string { + return `"${term.replace(/"/g, '""')}"` +} + +function escapeRegex(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +export class SearchService { + query(db: SqliteDb, params: SearchQueryParams): SearchResult[] { + const text = params.text.trim() + if (!text) return [] + + if (params.regex) { + return this.queryRegex(db, text, params.caseSensitive ?? false) + } + + try { + const ftsQuery = text.split(/\s+/).filter(Boolean).map(escapeFtsTerm).join(' ') + const rows = db + .prepare( + `SELECT source_kind, source_id, title, body FROM search_fts WHERE search_fts MATCH ?` + ) + .all(ftsQuery) as { source_kind: string; source_id: string; title: string; body: string }[] + + if (rows.length > 0) { + return rows.map((row) => ({ + kind: row.source_kind as SearchSourceKind, + id: row.source_id, + title: row.title, + snippet: makeSnippet(row.body, text, params.caseSensitive ?? false) + })) + } + } catch { + /* fallback below */ + } + + return this.queryLike(db, text, params.caseSensitive ?? false) + } + + private queryLike(db: SqliteDb, text: string, caseSensitive: boolean): SearchResult[] { + const rows = db.prepare('SELECT source_kind, source_id, title, body FROM search_fts').all() as { + source_kind: string + source_id: string + title: string + body: string + }[] + + return rows + .filter((row) => { + const hay = caseSensitive ? row.body : row.body.toLowerCase() + const needle = caseSensitive ? text : text.toLowerCase() + const titleHay = caseSensitive ? row.title : row.title.toLowerCase() + return hay.includes(needle) || titleHay.includes(needle) + }) + .map((row) => ({ + kind: row.source_kind as SearchSourceKind, + id: row.source_id, + title: row.title, + snippet: makeSnippet(row.body, text, caseSensitive) + })) + } + + private queryRegex(db: SqliteDb, pattern: string, caseSensitive: boolean): SearchResult[] { + let re: RegExp + try { + re = new RegExp(pattern, caseSensitive ? '' : 'i') + } catch { + return [] + } + + const rows = db.prepare('SELECT source_kind, source_id, title, body FROM search_fts').all() as { + source_kind: string + source_id: string + title: string + body: string + }[] + + return rows + .filter((row) => re.test(row.body) || re.test(row.title)) + .map((row) => ({ + kind: row.source_kind as SearchSourceKind, + id: row.source_id, + title: row.title, + snippet: makeSnippet(row.body, pattern, caseSensitive) + })) + } + + replace( + db: SqliteDb, + params: SearchReplaceParams + ): { count: number; previews: SearchResult[] } { + const results = this.query(db, { + text: params.text, + regex: params.regex, + caseSensitive: params.caseSensitive + }) + + if (params.dryRun) { + return { count: results.length, previews: results.slice(0, 10) } + } + + let count = 0 + const flags = params.caseSensitive ? 'g' : 'gi' + const re = params.regex + ? new RegExp(params.text, flags) + : new RegExp(escapeRegex(params.text), flags) + + for (const hit of results) { + if (hit.kind === 'chapter') { + const row = db.prepare('SELECT content FROM chapters WHERE id = ?').get(hit.id) as + | { content: string } + | undefined + if (!row) continue + const next = row.content.replace(re, params.replacement) + if (next !== row.content) { + db.prepare(`UPDATE chapters SET content = ?, updated_at = datetime('now') WHERE id = ?`).run( + next, + hit.id + ) + count++ + } + } + } + + return { count, previews: results.slice(0, 10) } + } +} + +export const searchService = new SearchService() diff --git a/src/main/services/snapshot.service.ts b/src/main/services/snapshot.service.ts new file mode 100644 index 0000000..cb4bda4 --- /dev/null +++ b/src/main/services/snapshot.service.ts @@ -0,0 +1,72 @@ +import type { GlobalSettings } from '../../shared/types' +import type { SnapshotRepository } from '../db/repositories/snapshot.repo' + +interface AutoSaveSession { + bookId: string + chapterId: string + getContent: () => string + lastContent: string + timer: ReturnType +} + +export class SnapshotService { + private sessions = new Map() + + constructor(private getSettings: () => GlobalSettings) {} + + private sessionKey(bookId: string, chapterId: string): string { + return `${bookId}:${chapterId}` + } + + startAutoSave(bookId: string, chapterId: string, getContent: () => string, repo: SnapshotRepository): void { + const key = this.sessionKey(bookId, chapterId) + this.stopAutoSave(bookId, chapterId) + + const settings = this.getSettings() + const lastContent = getContent() + const timer = setInterval(() => { + const content = getContent() + const session = this.sessions.get(key) + if (!session || content === session.lastContent) return + + repo.create(chapterId, 'auto', content) + repo.pruneAuto(chapterId, settings.snapshotMaxAuto) + session.lastContent = content + }, settings.snapshotIntervalMs) + + this.sessions.set(key, { bookId, chapterId, getContent, lastContent, timer }) + } + + stopAutoSave(bookId: string, chapterId: string): void { + const key = this.sessionKey(bookId, chapterId) + const session = this.sessions.get(key) + if (!session) return + clearInterval(session.timer) + this.sessions.delete(key) + } + + onPersistSave(chapterId: string, content: string, repo: SnapshotRepository): void { + const settings = this.getSettings() + repo.create(chapterId, 'persist', content) + repo.prunePersist(chapterId, settings.snapshotMaxPersist) + } + + flushAllPersist(repos: Map): void { + for (const session of this.sessions.values()) { + const repo = repos.get(session.bookId) + if (!repo) continue + const content = session.getContent() + if (content !== session.lastContent) { + this.onPersistSave(session.chapterId, content, repo) + session.lastContent = content + } + } + } + + stopAll(): void { + for (const session of this.sessions.values()) { + clearInterval(session.timer) + } + this.sessions.clear() + } +} diff --git a/src/main/services/word-count.ts b/src/main/services/word-count.ts index 189cd74..9a948f1 100644 --- a/src/main/services/word-count.ts +++ b/src/main/services/word-count.ts @@ -1,5 +1,9 @@ +export function stripHtml(text: string): string { + return text.replace(/<[^>]+>/g, '') +} + export function countWords(text: string): number { - const stripped = text.replace(/<[^>]+>/g, '').replace(/\s+/g, '') + const stripped = stripHtml(text).replace(/\s+/g, '') const cjk = stripped.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) const latin = stripped.match(/[a-zA-Z0-9]+/g) return (cjk?.length ?? 0) + (latin?.length ?? 0) diff --git a/src/main/services/wordfreq.service.ts b/src/main/services/wordfreq.service.ts new file mode 100644 index 0000000..6f96cbb --- /dev/null +++ b/src/main/services/wordfreq.service.ts @@ -0,0 +1,64 @@ +import { stripHtml } from './word-count' +import type { WordFreqResult } from '../../shared/types' +import type { SqliteDb } from '../db/types' + +export interface WordFreqScope { + kind: 'book' | 'volume' | 'chapter' + chapterId?: string + volumeId?: string +} + +const TOP_N = 100 + +export class WordFreqService { + analyze(db: SqliteDb, scope: WordFreqScope): WordFreqResult { + const texts = this.collectTexts(db, scope) + const combined = texts.join('\n') + const plain = stripHtml(combined) + + const counts = new Map() + + const cjk = plain.match(/[\u4e00-\u9fff]/g) + if (cjk) { + for (const ch of cjk) { + counts.set(ch, (counts.get(ch) ?? 0) + 1) + } + } + + const latin = plain.match(/\b[a-zA-Z]{2,}\b/g) + if (latin) { + for (const word of latin) { + const key = word.toLowerCase() + counts.set(key, (counts.get(key) ?? 0) + 1) + } + } + + const words = [...counts.entries()] + .map(([token, count]) => ({ token, count })) + .sort((a, b) => b.count - a.count) + .slice(0, TOP_N) + + return { words } + } + + private collectTexts(db: SqliteDb, scope: WordFreqScope): string[] { + if (scope.kind === 'chapter' && scope.chapterId) { + const row = db.prepare('SELECT content FROM chapters WHERE id = ?').get(scope.chapterId) as + | { content: string } + | undefined + return row ? [row.content] : [] + } + + if (scope.kind === 'volume' && scope.volumeId) { + const rows = db + .prepare('SELECT content FROM chapters WHERE volume_id = ?') + .all(scope.volumeId) as { content: string }[] + return rows.map((r) => r.content) + } + + const rows = db.prepare('SELECT content FROM chapters').all() as { content: string }[] + return rows.map((r) => r.content) + } +} + +export const wordFreqService = new WordFreqService() diff --git a/src/preload/index.ts b/src/preload/index.ts index 7c0b0ce..bebc990 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,14 +2,25 @@ import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron' import { IPC } from '../shared/ipc-channels' import type { ActionId, + Bookmark, BookMeta, BookOpenResult, Chapter, CreateBookParams, GlobalSettings, + InspirationEntry, IpcResult, + LandmarkType, + OutlineItem, + OutlineStatus, + SearchResult, + SettingEntry, + SettingType, + Snapshot, + SnapshotType, UpdateChapterParams, - Volume + Volume, + WordFreqResult } from '../shared/types' const electronAPI = { @@ -58,6 +69,157 @@ const electronAPI = { delete: (bookId: string, chapterId: string): Promise> => ipcRenderer.invoke(IPC.CHAPTER_DELETE, { bookId, chapterId }) }, + outline: { + list: (bookId: string): Promise> => + ipcRenderer.invoke(IPC.OUTLINE_LIST, { bookId }), + create: ( + bookId: string, + title: string, + parentId?: string | null, + sortOrder?: number + ): Promise> => + ipcRenderer.invoke(IPC.OUTLINE_CREATE, { bookId, parentId, title, sortOrder }), + update: ( + bookId: string, + id: string, + patch: Partial<{ + title: string + description: string + status: OutlineStatus + expectedWordCount: number | null + chapterId: string | null + sortOrder: number + }> + ): Promise> => + ipcRenderer.invoke(IPC.OUTLINE_UPDATE, { bookId, id, patch }), + delete: (bookId: string, id: string): Promise> => + ipcRenderer.invoke(IPC.OUTLINE_DELETE, { bookId, id }), + move: ( + bookId: string, + id: string, + parentId: string | null, + sortOrder: number + ): Promise> => + ipcRenderer.invoke(IPC.OUTLINE_MOVE, { bookId, id, parentId, sortOrder }) + }, + setting: { + list: (bookId: string, type?: SettingType): Promise> => + ipcRenderer.invoke(IPC.SETTING_LIST, { bookId, type }), + create: (bookId: string, type: SettingType, name: string): Promise> => + ipcRenderer.invoke(IPC.SETTING_CREATE, { bookId, type, name }), + update: ( + bookId: string, + id: string, + patch: Partial<{ name: string; description: string; type: SettingType; properties: Record }> + ): Promise> => + ipcRenderer.invoke(IPC.SETTING_UPDATE, { bookId, id, patch }), + delete: (bookId: string, id: string): Promise> => + ipcRenderer.invoke(IPC.SETTING_DELETE, { bookId, id }), + syncRefs: (bookId: string, id: string, chapterIds: string[]): Promise> => + ipcRenderer.invoke(IPC.SETTING_SYNC_REFS, { bookId, id, chapterIds }) + }, + inspiration: { + list: (bookId: string): Promise> => + ipcRenderer.invoke(IPC.INSPIRATION_LIST, { bookId }), + create: ( + bookId: string, + title?: string, + content?: string, + tags?: string[] + ): Promise> => + ipcRenderer.invoke(IPC.INSPIRATION_CREATE, { bookId, title, content, tags }), + update: ( + bookId: string, + id: string, + patch: Partial<{ title: string; content: string; tags: string[] }> + ): Promise> => + ipcRenderer.invoke(IPC.INSPIRATION_UPDATE, { bookId, id, patch }), + delete: (bookId: string, id: string): Promise> => + ipcRenderer.invoke(IPC.INSPIRATION_DELETE, { bookId, id }), + convert: ( + bookId: string, + id: string, + targetKind: 'outline' | 'setting', + settingType?: SettingType + ): Promise> => + ipcRenderer.invoke(IPC.INSPIRATION_CONVERT, { bookId, id, targetKind, settingType }) + }, + snapshot: { + list: (bookId: string, chapterId: string): Promise> => + ipcRenderer.invoke(IPC.SNAPSHOT_LIST, { bookId, chapterId }), + create: ( + bookId: string, + chapterId: string, + type: SnapshotType, + name?: string, + content?: string + ): Promise> => + ipcRenderer.invoke(IPC.SNAPSHOT_CREATE, { bookId, chapterId, type, name, content }), + restore: ( + bookId: string, + chapterId: string, + snapshotId: string + ): Promise> => + ipcRenderer.invoke(IPC.SNAPSHOT_RESTORE, { bookId, chapterId, snapshotId }), + delete: (bookId: string, snapshotId: string): Promise> => + ipcRenderer.invoke(IPC.SNAPSHOT_DELETE, { bookId, snapshotId }), + startAutoSave: (bookId: string, chapterId: string): Promise> => + ipcRenderer.invoke(IPC.SNAPSHOT_START_AUTO, { bookId, chapterId }), + stopAutoSave: (bookId: string, chapterId: string): Promise> => + ipcRenderer.invoke(IPC.SNAPSHOT_STOP_AUTO, { bookId, chapterId }) + }, + bookmark: { + list: ( + bookId: string, + chapterId?: string, + resolved?: boolean + ): Promise> => + ipcRenderer.invoke(IPC.BOOKMARK_LIST, { bookId, chapterId, resolved }), + create: ( + bookId: string, + chapterId: string, + position: number, + label: string, + landmarkType?: LandmarkType + ): Promise> => + ipcRenderer.invoke(IPC.BOOKMARK_CREATE, { bookId, chapterId, position, label, landmarkType }), + update: ( + bookId: string, + id: string, + patch: Partial<{ label: string; position: number; landmarkType: LandmarkType }> + ): Promise> => + ipcRenderer.invoke(IPC.BOOKMARK_UPDATE, { bookId, id, patch }), + resolve: (bookId: string, id: string, resolved: boolean): Promise> => + ipcRenderer.invoke(IPC.BOOKMARK_RESOLVE, { bookId, id, resolved }), + delete: (bookId: string, id: string): Promise> => + ipcRenderer.invoke(IPC.BOOKMARK_DELETE, { bookId, id }) + }, + search: { + query: ( + bookId: string, + query: string, + options?: { regex?: boolean; caseSensitive?: boolean; matchWholeWord?: boolean } + ): Promise> => + ipcRenderer.invoke(IPC.SEARCH_QUERY, { bookId, query, options }), + replace: ( + bookId: string, + query: string, + replacement: string, + dryRun: boolean, + options?: { regex?: boolean; caseSensitive?: boolean } + ): Promise> => + ipcRenderer.invoke(IPC.SEARCH_REPLACE, { bookId, query, replacement, dryRun, options }), + getHistory: (): Promise> => ipcRenderer.invoke(IPC.SEARCH_GET_HISTORY), + saveHistory: (query: string): Promise> => + ipcRenderer.invoke(IPC.SEARCH_SAVE_HISTORY, { query }) + }, + wordfreq: { + analyze: ( + bookId: string, + scope: { kind: 'book' | 'volume' | 'chapter'; chapterId?: string; volumeId?: string } + ): Promise> => + ipcRenderer.invoke(IPC.WORDFREQ_ANALYZE, { bookId, scope }) + }, shortcut: { getAll: (): Promise>> => ipcRenderer.invoke(IPC.SHORTCUT_GET_ALL), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 094d13f..6582171 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -14,7 +14,12 @@ import { useAppStore } from '@renderer/stores/useAppStore' import { useSettingsStore } from '@renderer/stores/useSettingsStore' import { useTabStore } from '@renderer/stores/useTabStore' import { useBookStore } from '@renderer/stores/useBookStore' +import { useEditStore } from '@renderer/stores/useEditStore' +import { SearchModal } from '@renderer/components/search/SearchModal' +import { useSearchStore } from '@renderer/stores/useSearchStore' +import { useReferenceStore } from '@renderer/stores/useReferenceStore' import { flushEditorSave } from '@renderer/components/editor/TipTapEditor' +import { insertLandmarkInEditor } from '@renderer/lib/editor-commands' import { ipcCall } from '@renderer/lib/ipc-client' function AppInner(): React.JSX.Element { @@ -40,11 +45,35 @@ function AppInner(): React.JSX.Element { if (loaded && !onboardingCompleted) setShowOnboarding(true) }, [loaded, onboardingCompleted]) + useEffect(() => { + const openSearch = (): void => useSearchStore.getState().setOpen(true) + const openLandmarks = (): void => useAppStore.getState().setLandmarksModalOpen(true) + const onInsertLandmark = (e: Event): void => { + const label = (e as CustomEvent<{ label: string }>).detail?.label + if (!label) return + const { currentBookId } = useBookStore.getState() + const target = useEditStore.getState().target + if (!currentBookId || target?.kind !== 'chapter') return + insertLandmarkInEditor({ label, landmarkType: 'todo' }) + void ipcCall(() => + window.electronAPI.bookmark.create(currentBookId, target.id, 0, label, 'todo') + ) + } + window.addEventListener('bilin:e2e-open-search', openSearch) + window.addEventListener('bilin:e2e-open-landmarks', openLandmarks) + window.addEventListener('bilin:e2e-insert-landmark', onInsertLandmark) + return () => { + window.removeEventListener('bilin:e2e-open-search', openSearch) + window.removeEventListener('bilin:e2e-open-landmarks', openLandmarks) + window.removeEventListener('bilin:e2e-insert-landmark', onInsertLandmark) + } + }, []) + useEffect(() => { const unsub = window.electronAPI.onShortcutTriggered(async (action) => { if (action === 'saveChapter') { try { - await flushEditorSave() + await flushEditorSave(true) } catch { showToast(t('toast.saveFailed')) } @@ -75,6 +104,37 @@ function AppInner(): React.JSX.Element { } else setView('home') return } + if (action === 'globalSearch') { + useSearchStore.getState().setOpen(true) + return + } + if (action === 'openReference') { + useReferenceStore.getState().setOpen(true) + return + } + if (action === 'openLandmarks') { + useAppStore.getState().setLandmarksModalOpen(true) + return + } + if (action === 'insertLandmark') { + if (view !== 'editor') return + document.querySelector('[data-testid="insert-landmark"]')?.click() + return + } + if (action === 'captureInspiration') { + if (view !== 'editor') return + const { currentBookId, addInspirationLocal } = useBookStore.getState() + if (!currentBookId) return + void (async () => { + const item = await ipcCall(() => + window.electronAPI.inspiration.create(currentBookId, t('inspiration.newItem')) + ) + addInspirationLocal(item) + useAppStore.getState().setSidebarPanel('inspiration') + await useEditStore.getState().switchTarget({ kind: 'inspiration', id: item.id }) + })() + return + } showToast(t('feature.comingSoon')) }) return unsub @@ -93,6 +153,7 @@ function AppInner(): React.JSX.Element { {view === 'settings' && } {showOnboarding && } + {toast &&
{toast}
} ) diff --git a/src/renderer/components/editor/TipTapEditor.tsx b/src/renderer/components/editor/TipTapEditor.tsx index efe31eb..f491f41 100644 --- a/src/renderer/components/editor/TipTapEditor.tsx +++ b/src/renderer/components/editor/TipTapEditor.tsx @@ -2,145 +2,353 @@ import { useEditor, EditorContent } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Underline from '@tiptap/extension-underline' import Placeholder from '@tiptap/extension-placeholder' -import { useEffect, useRef, useCallback } from 'react' +import { useEffect, useRef, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSetAtom } from 'jotai' -import type { Chapter } from '@shared/types' +import type { EditTarget } from '@shared/types' import { editorDirtyAtom, editorSavingAtom, lastSavedAtAtom, wordCountAtom } from '@renderer/atoms/editorAtoms' import { useBookStore } from '@renderer/stores/useBookStore' +import { flushEditSession, registerEditorFlush } from '@renderer/stores/useEditStore' import { ipcCall } from '@renderer/lib/ipc-client' - +import { LandmarkMark } from '@renderer/extensions/landmark-mark' +import { SettingMention } from '@renderer/extensions/mention' +import { registerHighlightToken, registerInsertLandmark } from '@renderer/lib/editor-commands' function countPlain(text: string): number { - const stripped = text.replace(/\s+/g, '') + const stripped = text.replace(/<[^>]+>/g, '').replace(/\s+/g, '') const cjk = stripped.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) const latin = stripped.match(/[a-zA-Z0-9]+/g) return (cjk?.length ?? 0) + (latin?.length ?? 0) } +let persistOnNextSave = false + +function findTokenRange(doc: { descendants: (f: (node: { isText?: boolean; text?: string | null }, pos: number) => boolean | void) => void }, token: string): { from: number; to: number } | null { + let found: { from: number; to: number } | null = null + doc.descendants((node, pos) => { + if (found || !node.isText || !node.text) return + const idx = node.text.indexOf(token) + if (idx >= 0) { + found = { from: pos + idx, to: pos + idx + token.length } + return false + } + }) + return found +} + +const simplifiedKit = StarterKit.configure({ + heading: false, + bulletList: false, + orderedList: false, + blockquote: false, + codeBlock: false, + horizontalRule: false, + strike: false, + code: false +}) + interface TipTapEditorProps { - chapter: Chapter | null + target: EditTarget | null bookId: string | null } -export function TipTapEditor({ chapter, bookId }: TipTapEditorProps): React.JSX.Element { +function useTargetContent(target: EditTarget | null): string { + const chapters = useBookStore((s) => s.chapters) + const outlines = useBookStore((s) => s.outlines) + const settings = useBookStore((s) => s.settings) + const inspirations = useBookStore((s) => s.inspirations) + + if (!target) return '' + if (target.kind === 'chapter') { + return chapters.find((c) => c.id === target.id)?.content ?? '' + } + if (target.kind === 'outline') { + return outlines.find((o) => o.id === target.id)?.description ?? '' + } + if (target.kind === 'setting') { + return settings.find((s) => s.id === target.id)?.description ?? '' + } + return inspirations.find((i) => i.id === target.id)?.content ?? '' +} + +export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.Element { const { t, i18n } = useTranslation() const setDirty = useSetAtom(editorDirtyAtom) const setSaving = useSetAtom(editorSavingAtom) const setLastSaved = useSetAtom(lastSavedAtAtom) const setWordCount = useSetAtom(wordCountAtom) - const updateChapterLocal = useBookStore((s) => s.updateChapterLocal) const chapters = useBookStore((s) => s.chapters) + const updateChapterLocal = useBookStore((s) => s.updateChapterLocal) + const updateOutlineLocal = useBookStore((s) => s.updateOutlineLocal) + const updateSettingLocal = useBookStore((s) => s.updateSettingLocal) + const updateInspirationLocal = useBookStore((s) => s.updateInspirationLocal) + const settings = useBookStore((s) => s.settings) const timerRef = useRef>() - const chapterIdRef = useRef(null) + const targetKeyRef = useRef(null) + const [mentionOpen, setMentionOpen] = useState(false) + const [mentionQuery, setMentionQuery] = useState('') + const [mentionFrom, setMentionFrom] = useState(0) + const content = useTargetContent(target) + const isChapter = target?.kind === 'chapter' - const editor = useEditor({ - extensions: [ - StarterKit, - Underline, - Placeholder.configure({ placeholder: t('editor.placeholder') }) - ], - content: chapter?.content ?? '', - onUpdate: ({ editor: ed }) => { - setDirty(true) - clearTimeout(timerRef.current) - timerRef.current = setTimeout(() => { - void (window as unknown as { __bilinSave?: () => Promise }).__bilinSave?.() - }, 2000) - const text = ed.getText() - const volId = chapter?.volumeId - const chapterCount = countPlain(text) - const volumeTotal = chapters - .filter((c) => c.volumeId === volId) - .reduce((sum, c) => sum + (c.id === chapter?.id ? chapterCount : c.wordCount), 0) - const bookTotal = chapters.reduce( - (sum, c) => sum + (c.id === chapter?.id ? chapterCount : c.wordCount), - 0 - ) - setWordCount({ chapter: chapterCount, volume: volumeTotal, book: bookTotal }) - }, - editorProps: { - handlePaste: (view, event) => { - if (event.shiftKey) return false - const text = event.clipboardData?.getData('text/plain') - if (text) { - view.dispatch(view.state.tr.insertText(text)) + const extensions = useMemo( + () => + isChapter + ? [ + StarterKit, + Underline, + LandmarkMark, + SettingMention, + Placeholder.configure({ placeholder: t('editor.placeholder') }) + ] + : [simplifiedKit, Placeholder.configure({ placeholder: t('editor.placeholder') })], + [isChapter, t] + ) + + const mentionCandidates = useMemo(() => { + const q = mentionQuery.toLowerCase() + return settings + .filter((s) => !q || s.name.toLowerCase().includes(q)) + .slice(0, 8) + }, [settings, mentionQuery]) + + const editor = useEditor( + { + extensions, + content: '', + onUpdate: ({ editor: ed }) => { + setDirty(true) + clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => { + void flushEditorSave() + }, 2000) + const { from } = ed.state.selection + const textBefore = ed.state.doc.textBetween(Math.max(0, from - 40), from, '\n', '\n') + const match = textBefore.match(/@([\u4e00-\u9fff\w]*)$/) + if (match && isChapter) { + setMentionQuery(match[1]) + setMentionFrom(from - match[0].length) + setMentionOpen(true) + } else { + setMentionOpen(false) + } + const text = ed.getText() + const chapterCount = countPlain(text) + if (isChapter && target?.kind === 'chapter') { + const ch = chapters.find((c) => c.id === target.id) + const volId = ch?.volumeId + const volumeTotal = chapters + .filter((c) => c.volumeId === volId) + .reduce((sum, c) => sum + (c.id === target.id ? chapterCount : c.wordCount), 0) + const bookTotal = chapters.reduce( + (sum, c) => sum + (c.id === target.id ? chapterCount : c.wordCount), + 0 + ) + setWordCount({ chapter: chapterCount, volume: volumeTotal, book: bookTotal }) + } else { + setWordCount({ chapter: chapterCount, volume: chapterCount, book: chapterCount }) + } + }, + editorProps: { + handleDOMEvents: { + dragover: (_view, event) => { + event.preventDefault() + return true + } + }, + handlePaste: (view, event) => { + if (event.shiftKey) return false + const text = event.clipboardData?.getData('text/plain') + if (text) { + view.dispatch(view.state.tr.insertText(text)) + return true + } + return false + }, + handleDrop: (view, event) => { + const text = event.dataTransfer?.getData('text/plain') + if (!text) return false + event.preventDefault() + const coords = view.posAtCoords({ left: event.clientX, top: event.clientY }) + const pos = coords?.pos ?? view.state.selection.from + view.dispatch(view.state.tr.insertText(text, pos)) return true } - return false } - } - }, [i18n.language]) + }, + [target?.kind, target?.id, i18n.language] + ) const save = useCallback(async () => { - if (!bookId || !chapter || !editor) return + if (!bookId || !target || !editor) return setSaving(true) try { const html = editor.getHTML() const text = editor.getText() - const updated = await ipcCall(() => - window.electronAPI.chapter.update({ - bookId, - chapterId: chapter.id, - content: html, - cursorOffset: editor.state.selection.anchor - }) - ) - updateChapterLocal(updated) + + if (target.kind === 'chapter') { + const updated = await ipcCall(() => + window.electronAPI.chapter.update({ + bookId, + chapterId: target.id, + content: html, + cursorOffset: editor.state.selection.anchor + }) + ) + updateChapterLocal(updated) + if (persistOnNextSave) { + await ipcCall(() => + window.electronAPI.snapshot.create(bookId, target.id, 'persist', '', html) + ) + } + const volId = updated.volumeId + const volumeTotal = chapters + .map((c) => (c.id === updated.id ? updated : c)) + .filter((c) => c.volumeId === volId) + .reduce((sum, c) => sum + (c.id === updated.id ? countPlain(text) : c.wordCount), 0) + const bookTotal = chapters + .map((c) => (c.id === updated.id ? updated : c)) + .reduce((sum, c) => sum + (c.id === updated.id ? countPlain(text) : c.wordCount), 0) + setWordCount({ chapter: countPlain(text), volume: volumeTotal, book: bookTotal }) + } else if (target.kind === 'outline') { + const updated = await ipcCall(() => + window.electronAPI.outline.update(bookId, target.id, { description: html }) + ) + updateOutlineLocal(updated) + setWordCount({ chapter: countPlain(text), volume: countPlain(text), book: countPlain(text) }) + } else if (target.kind === 'setting') { + const updated = await ipcCall(() => + window.electronAPI.setting.update(bookId, target.id, { description: html }) + ) + updateSettingLocal(updated) + setWordCount({ chapter: countPlain(text), volume: countPlain(text), book: countPlain(text) }) + } else if (target.kind === 'inspiration') { + const updated = await ipcCall(() => + window.electronAPI.inspiration.update(bookId, target.id, { content: html }) + ) + updateInspirationLocal(updated) + setWordCount({ chapter: countPlain(text), volume: countPlain(text), book: countPlain(text) }) + } + setDirty(false) setLastSaved(new Date()) - const volId = updated.volumeId - const volumeTotal = chapters - .map((c) => (c.id === updated.id ? updated : c)) - .filter((c) => c.volumeId === volId) - .reduce((sum, c) => sum + (c.id === updated.id ? countPlain(text) : c.wordCount), 0) - const bookTotal = chapters - .map((c) => (c.id === updated.id ? updated : c)) - .reduce((sum, c) => sum + (c.id === updated.id ? countPlain(text) : c.wordCount), 0) - setWordCount({ chapter: countPlain(text), volume: volumeTotal, book: bookTotal }) } finally { setSaving(false) } - }, [bookId, chapter, chapters, editor, setDirty, setLastSaved, setSaving, setWordCount, updateChapterLocal]) + }, [ + bookId, + target, + editor, + chapters, + setDirty, + setLastSaved, + setSaving, + setWordCount, + updateChapterLocal, + updateOutlineLocal, + updateSettingLocal, + updateInspirationLocal + ]) useEffect(() => { - ;(window as unknown as { __bilinSave?: () => Promise }).__bilinSave = save + registerEditorFlush(save) return () => { + registerEditorFlush(async () => {}) clearTimeout(timerRef.current) - delete (window as unknown as { __bilinSave?: () => Promise }).__bilinSave } }, [save]) useEffect(() => { - if (!editor || !chapter) return - if (chapterIdRef.current !== chapter.id) { - void (async () => { - await flushEditorSave() - chapterIdRef.current = chapter.id - editor.commands.setContent(chapter.content || '') - const pos = Math.min(chapter.cursorOffset ?? 0, editor.state.doc.content.size) - editor.commands.focus(pos) - setDirty(false) - setWordCount({ - chapter: chapter.wordCount, - volume: chapters.filter((c) => c.volumeId === chapter.volumeId).reduce((s, c) => s + c.wordCount, 0), - book: chapters.reduce((s, c) => s + c.wordCount, 0) - }) - })() + if (!editor) return + registerInsertLandmark(({ label, landmarkType = 'todo' }) => { + editor + .chain() + .focus() + .setMark('landmark', { label, landmarkType }) + .insertContent(`[TODO: ${label}]`) + .unsetMark('landmark') + .insertContent(' ') + .run() + }) + registerHighlightToken((token) => { + const range = findTokenRange(editor.state.doc, token) + if (!range) return + editor.chain().focus().setTextSelection(range).run() + document.querySelector('.editor-content-wrap')?.setAttribute('data-highlight-token', token) + }) + return () => { + registerInsertLandmark(() => {}) + registerHighlightToken(() => {}) } - }, [chapter, editor, chapters, setDirty, setWordCount]) + }, [editor]) - if (!chapter) { + useEffect(() => { + if (!editor || !target) return + const key = `${target.kind}:${target.id}` + if (targetKeyRef.current === key) return + targetKeyRef.current = key + + editor.commands.setContent(content || '') + if (target.kind === 'chapter') { + const ch = chapters.find((c) => c.id === target.id) + const pos = Math.min(ch?.cursorOffset ?? 0, editor.state.doc.content.size) + editor.commands.focus(pos) + setWordCount({ + chapter: ch?.wordCount ?? 0, + volume: chapters.filter((c) => c.volumeId === ch?.volumeId).reduce((s, c) => s + c.wordCount, 0), + book: chapters.reduce((s, c) => s + c.wordCount, 0) + }) + } else { + editor.commands.focus('end') + const wc = countPlain(editor.getText()) + setWordCount({ chapter: wc, volume: wc, book: wc }) + } + setDirty(false) + }, [target, content, editor, chapters, setDirty, setWordCount]) + + const applyMention = (id: string, label: string): void => { + if (!editor) return + editor + .chain() + .focus() + .deleteRange({ from: mentionFrom, to: editor.state.selection.from }) + .insertContent( + `@${label} ` + ) + .run() + setMentionOpen(false) + } + + if (!target) { return
{t('editor.placeholder')}
} return (
+ {mentionOpen && mentionCandidates.length > 0 && ( +
+ {mentionCandidates.map((s) => ( + + ))} +
+ )}
) } -export async function flushEditorSave(): Promise { - const fn = (window as unknown as { __bilinSave?: () => Promise }).__bilinSave - if (fn) await fn() +export async function flushEditorSave(persist = false): Promise { + persistOnNextSave = persist + await flushEditSession() + persistOnNextSave = false } diff --git a/src/renderer/components/inspiration/InspirationList.tsx b/src/renderer/components/inspiration/InspirationList.tsx new file mode 100644 index 0000000..7d6c2de --- /dev/null +++ b/src/renderer/components/inspiration/InspirationList.tsx @@ -0,0 +1,55 @@ +import { useTranslation } from 'react-i18next' +import { useBookStore } from '@renderer/stores/useBookStore' +import { useEditStore } from '@renderer/stores/useEditStore' +import { ipcCall } from '@renderer/lib/ipc-client' + +export function InspirationList(): React.JSX.Element { + const { t } = useTranslation() + const bookId = useBookStore((s) => s.currentBookId) + const inspirations = useBookStore((s) => s.inspirations) + const addInspirationLocal = useBookStore((s) => s.addInspirationLocal) + const target = useEditStore((s) => s.target) + const switchTarget = useEditStore((s) => s.switchTarget) + + const activeId = target?.kind === 'inspiration' ? target.id : null + + const handleCreate = async (): Promise => { + if (!bookId) return + const item = await ipcCall(() => + window.electronAPI.inspiration.create(bookId, t('inspiration.newItem')) + ) + addInspirationLocal(item) + await switchTarget({ kind: 'inspiration', id: item.id }) + } + + return ( + <> +
+ {inspirations.length === 0 && ( +
{t('inspiration.empty')}
+ )} + {inspirations.map((item) => ( +
void switchTarget({ kind: 'inspiration', id: item.id })} + onKeyDown={(e) => e.key === 'Enter' && void switchTarget({ kind: 'inspiration', id: item.id })} + role="button" + tabIndex={0} + data-testid={`inspiration-item-${item.id}`} + > +
{item.title || t('inspiration.untitled')}
+
+ {item.content.replace(/<[^>]+>/g, '').slice(0, 40) || t('inspiration.noContent')} +
+
+ ))} +
+
+ +
+ + ) +} diff --git a/src/renderer/components/landmark/LandmarkModal.tsx b/src/renderer/components/landmark/LandmarkModal.tsx new file mode 100644 index 0000000..0c33ef8 --- /dev/null +++ b/src/renderer/components/landmark/LandmarkModal.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { Bookmark } from '@shared/types' +import { useBookStore } from '@renderer/stores/useBookStore' +import { useEditStore } from '@renderer/stores/useEditStore' +import { ipcCall } from '@renderer/lib/ipc-client' + +interface LandmarkModalProps { + open: boolean + onClose: () => void +} + +export function LandmarkModal({ open, onClose }: LandmarkModalProps): React.JSX.Element | null { + const { t } = useTranslation() + const bookId = useBookStore((s) => s.currentBookId) + const chapters = useBookStore((s) => s.chapters) + const setSelectedChapter = useBookStore((s) => s.setSelectedChapter) + const switchTarget = useEditStore((s) => s.switchTarget) + const [bookmarks, setBookmarks] = useState([]) + + useEffect(() => { + if (!open || !bookId) return + void ipcCall(() => window.electronAPI.bookmark.list(bookId)).then(setBookmarks) + }, [open, bookId]) + + const jumpTo = async (bm: Bookmark): Promise => { + setSelectedChapter(bm.chapterId) + await switchTarget({ kind: 'chapter', id: bm.chapterId }) + onClose() + } + + if (!open) return null + + return ( +
+
+
+

{t('landmark.title')}

+ +
+
+ {bookmarks.map((bm) => { + const ch = chapters.find((c) => c.id === bm.chapterId) + return ( +
void jumpTo(bm)} + role="button" + tabIndex={0} + data-testid={`landmark-item-${bm.id}`} + > +
+ {bm.landmarkType} · {ch?.title ?? bm.chapterId} +
+
{bm.label}
+
+ ) + })} + {bookmarks.length === 0 &&
{t('landmark.empty')}
} +
+
+ +
+
+
+ ) +} diff --git a/src/renderer/components/layout/EditorLayout.tsx b/src/renderer/components/layout/EditorLayout.tsx index f23943a..433aa85 100644 --- a/src/renderer/components/layout/EditorLayout.tsx +++ b/src/renderer/components/layout/EditorLayout.tsx @@ -1,10 +1,22 @@ +import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useAtomValue } from 'jotai' import { useAppStore } from '@renderer/stores/useAppStore' import { useBookStore } from '@renderer/stores/useBookStore' +import { useEditStore } from '@renderer/stores/useEditStore' +import { useOutlineStore } from '@renderer/stores/useOutlineStore' +import { useReferenceStore } from '@renderer/stores/useReferenceStore' import { ipcCall } from '@renderer/lib/ipc-client' import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor' +import { insertLandmarkInEditor } from '@renderer/lib/editor-commands' import { RightPanel } from '@renderer/components/layout/RightPanel' +import { ReferencePanel } from '@renderer/components/reference/ReferencePanel' +import { OutlineTree } from '@renderer/components/outline/OutlineTree' +import { OutlineCompareView } from '@renderer/components/outline/OutlineCompareView' +import { SettingList } from '@renderer/components/setting/SettingList' +import { InspirationList } from '@renderer/components/inspiration/InspirationList' +import { VersionModal } from '@renderer/components/version/VersionModal' +import { LandmarkModal } from '@renderer/components/landmark/LandmarkModal' import { editorDirtyAtom, editorSavingAtom, @@ -12,11 +24,40 @@ import { wordCountAtom } from '@renderer/atoms/editorAtoms' +function useDocumentTitle(): string { + const target = useEditStore((s) => s.target) + const chapters = useBookStore((s) => s.chapters) + const outlines = useBookStore((s) => s.outlines) + const settings = useBookStore((s) => s.settings) + const inspirations = useBookStore((s) => s.inspirations) + + return useMemo(() => { + if (!target) return '—' + if (target.kind === 'chapter') { + return chapters.find((c) => c.id === target.id)?.title ?? '—' + } + if (target.kind === 'outline') { + return outlines.find((o) => o.id === target.id)?.title ?? '—' + } + if (target.kind === 'setting') { + return settings.find((s) => s.id === target.id)?.name ?? '—' + } + return inspirations.find((i) => i.id === target.id)?.title ?? '—' + }, [target, chapters, outlines, settings, inspirations]) +} + export function EditorLayout(): React.JSX.Element { const { t } = useTranslation() const showToast = useAppStore((s) => s.showToast) - const sidebarPanel = useAppStore((s) => s.setSidebarPanel) + const setSidebarPanel = useAppStore((s) => s.setSidebarPanel) const activePanel = useAppStore((s) => s.sidebarPanel) + const versionModalOpen = useAppStore((s) => s.versionModalOpen) + const landmarksModalOpen = useAppStore((s) => s.landmarksModalOpen) + const setVersionModalOpen = useAppStore((s) => s.setVersionModalOpen) + const setLandmarksModalOpen = useAppStore((s) => s.setLandmarksModalOpen) + const compareMode = useOutlineStore((s) => s.compareMode) + const referenceOpen = useReferenceStore((s) => s.open) + const setReferenceOpen = useReferenceStore((s) => s.setOpen) const { currentBookId, volumes, @@ -27,18 +68,38 @@ export function EditorLayout(): React.JSX.Element { setActiveVolume, refreshChapters } = useBookStore() + const target = useEditStore((s) => s.target) + const switchTarget = useEditStore((s) => s.switchTarget) + const setTarget = useEditStore((s) => s.setTarget) const dirty = useAtomValue(editorDirtyAtom) const saving = useAtomValue(editorSavingAtom) const lastSaved = useAtomValue(lastSavedAtAtom) const wordCount = useAtomValue(wordCountAtom) + const documentTitle = useDocumentTitle() - const currentChapter = chapters.find((c) => c.id === selectedChapterId) ?? null const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId) + const isChapterTarget = target?.kind === 'chapter' + const chapterId = target?.kind === 'chapter' ? target.id : selectedChapterId + + useEffect(() => { + if (selectedChapterId && (!target || target.kind === 'chapter')) { + setTarget({ kind: 'chapter', id: selectedChapterId }) + } + }, [selectedChapterId, setTarget]) + + useEffect(() => { + if (!currentBookId || target?.kind !== 'chapter') return + const chapterId = target.id + void ipcCall(() => window.electronAPI.snapshot.startAutoSave(currentBookId, chapterId)) + return () => { + void ipcCall(() => window.electronAPI.snapshot.stopAutoSave(currentBookId, chapterId)) + } + }, [currentBookId, target?.kind, target?.id]) const handleSelectChapter = async (chapterId: string): Promise => { - await flushEditorSave() setSelectedChapter(chapterId) + await switchTarget({ kind: 'chapter', id: chapterId }) } const handleNewChapter = async (): Promise => { @@ -49,6 +110,7 @@ export function EditorLayout(): React.JSX.Element { ) await refreshChapters() setSelectedChapter(ch.id) + await switchTarget({ kind: 'chapter', id: ch.id }) } const handleNewVolume = async (): Promise => { @@ -60,10 +122,24 @@ export function EditorLayout(): React.JSX.Element { setActiveVolume(vol.id) } + const handleInsertLandmark = async (): Promise => { + if (!currentBookId || target?.kind !== 'chapter') { + showToast(t('landmark.chapterOnly')) + return + } + const label = prompt(t('landmark.prompt')) + if (!label?.trim()) return + insertLandmarkInEditor({ label: label.trim(), landmarkType: 'todo' }) + await ipcCall(() => + window.electronAPI.bookmark.create(currentBookId, target.id, 0, label.trim(), 'todo') + ) + showToast(t('landmark.created')) + } + const saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved') return ( -
+
) } diff --git a/src/renderer/components/layout/RightPanel.tsx b/src/renderer/components/layout/RightPanel.tsx index 4c48e9d..d2ca733 100644 --- a/src/renderer/components/layout/RightPanel.tsx +++ b/src/renderer/components/layout/RightPanel.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next' import { useAppStore } from '@renderer/stores/useAppStore' +import { WordFreqPanel } from '@renderer/components/wordfreq/WordFreqPanel' export function RightPanel(): React.JSX.Element { const { t } = useTranslation() @@ -20,6 +21,7 @@ export function RightPanel(): React.JSX.Element { key={tab.id} type="button" className={`panel-tab ${rightPanel === tab.id ? 'active' : ''}`} + data-testid={`panel-tab-${tab.id}`} onClick={() => setRightPanel(tab.id)} > {tab.label} @@ -30,11 +32,15 @@ export function RightPanel(): React.JSX.Element {

{t('panel.aiPlaceholder')}

- {(['knowledge', 'wordfreq', 'book-settings'] as const).map((id) => ( -
-
{t('feature.comingSoon')}
-
- ))} +
+
{t('feature.comingSoon')}
+
+
+ +
+
+
{t('feature.comingSoon')}
+
) } diff --git a/src/renderer/components/onboarding/OnboardingWizard.tsx b/src/renderer/components/onboarding/OnboardingWizard.tsx index 82de3f2..300ec6d 100644 --- a/src/renderer/components/onboarding/OnboardingWizard.tsx +++ b/src/renderer/components/onboarding/OnboardingWizard.tsx @@ -54,10 +54,11 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps): React.J

{t('onboarding.penName')}

setPenName(e.target.value)} placeholder={t('onboarding.penNamePlaceholder')} - style={{ width: '100%', marginTop: 12, padding: 10, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--bg-tertiary)', color: 'inherit' }} + style={{ marginTop: 12 }} />