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 <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# 笔临 (Bilin)
|
# 笔临 (Bilin)
|
||||||
|
|
||||||
长篇创作智能协作平台 — Electron 桌面客户端 v0.1.0(P0/P1)
|
长篇创作智能协作平台 — Electron 桌面客户端 v0.2.0(P2)
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
@@ -14,13 +14,14 @@ npm run dev
|
|||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test # 单元测试 6/6
|
|
||||||
npm run test:e2e # E2E 测试(需先 build)
|
|
||||||
npm run build
|
npm run build
|
||||||
|
npm run test # 单元测试
|
||||||
|
npm run test:e2e # E2E 测试(需先 build)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
- 设计规格:`docs/superpowers/specs/2026-07-05-bilin-p0-p1-design.md`
|
- P2 设计规格:`docs/superpowers/specs/2026-07-06-bilin-p2-design.md`
|
||||||
- 实现计划:`docs/superpowers/plans/2026-07-05-bilin-p0-p1.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`
|
- UI 原型:`design/ui_pc.html`
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
await page.waitForLoadState('domcontentloaded')
|
||||||
|
if (await page.getByText('欢迎使用笔临').isVisible()) {
|
||||||
|
await page.getByRole('button', { name: '跳过' }).click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAndOpenBook(page: Page): Promise<void> {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<void> {
|
||||||
|
await page.waitForLoadState('domcontentloaded')
|
||||||
|
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
|
||||||
|
if (await page.getByText('欢迎使用笔临').isVisible()) {
|
||||||
|
await page.getByRole('button', { name: '跳过' }).click()
|
||||||
|
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAndOpenBook(page: Page, name: string): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /新建书籍/ }).first().click()
|
||||||
|
await page.locator('.dialog-content input').first().fill(name)
|
||||||
|
await page.getByRole('button', { name: '创建' }).click()
|
||||||
|
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<void> {
|
||||||
|
await page.waitForLoadState('domcontentloaded')
|
||||||
|
if (await page.getByText('欢迎使用笔临').isVisible()) {
|
||||||
|
await page.getByRole('button', { name: '跳过' }).click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAndOpenBook(page: Page, name = 'P2测试书'): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /新建书籍/ }).first().click()
|
||||||
|
await page.locator('.dialog-content input').first().fill(name)
|
||||||
|
await page.getByRole('button', { name: '创建' }).click()
|
||||||
|
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureChapterEditor(page: Page): Promise<void> {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<void> {
|
||||||
|
await page.waitForLoadState('domcontentloaded')
|
||||||
|
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
|
||||||
|
if (await page.getByText('欢迎使用笔临').isVisible()) {
|
||||||
|
await page.getByRole('button', { name: '跳过' }).click()
|
||||||
|
await expect(page.getByText('欢迎使用笔临')).toBeHidden({ timeout: 10_000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSettings(page: Page): Promise<void> {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Generated
+533
-44
@@ -18,6 +18,7 @@
|
|||||||
"@tiptap/extension-underline": "^3.27.1",
|
"@tiptap/extension-underline": "^3.27.1",
|
||||||
"@tiptap/react": "^3.27.1",
|
"@tiptap/react": "^3.27.1",
|
||||||
"@tiptap/starter-kit": "^3.27.1",
|
"@tiptap/starter-kit": "^3.27.1",
|
||||||
|
"diff-match-patch": "^1.0.5",
|
||||||
"electron": "^36.9.0",
|
"electron": "^36.9.0",
|
||||||
"i18next": "^24.2.3",
|
"i18next": "^24.2.3",
|
||||||
"jotai": "^2.12.1",
|
"jotai": "^2.12.1",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.51.0",
|
"@playwright/test": "^1.51.0",
|
||||||
|
"@types/diff-match-patch": "^1.0.36",
|
||||||
"@types/node": "^22.13.0",
|
"@types/node": "^22.13.0",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
@@ -46,7 +48,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-validator-identifier": "^7.29.7",
|
"@babel/helper-validator-identifier": "^7.29.7",
|
||||||
@@ -61,7 +63,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||||
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -71,7 +73,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.7",
|
||||||
@@ -102,7 +104,7 @@
|
|||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@@ -112,7 +114,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||||
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.7",
|
"@babel/parser": "^7.29.7",
|
||||||
@@ -129,7 +131,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||||
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/compat-data": "^7.29.7",
|
"@babel/compat-data": "^7.29.7",
|
||||||
@@ -146,7 +148,7 @@
|
|||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@@ -156,7 +158,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||||
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -166,7 +168,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||||
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/traverse": "^7.29.7",
|
"@babel/traverse": "^7.29.7",
|
||||||
@@ -180,7 +182,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||||
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-module-imports": "^7.29.7",
|
"@babel/helper-module-imports": "^7.29.7",
|
||||||
@@ -208,7 +210,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -218,7 +220,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -228,7 +230,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||||
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -238,7 +240,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||||
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/template": "^7.29.7",
|
"@babel/template": "^7.29.7",
|
||||||
@@ -252,7 +254,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.29.7"
|
"@babel/types": "^7.29.7"
|
||||||
@@ -325,7 +327,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||||
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.7",
|
||||||
@@ -340,7 +342,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||||
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.7",
|
||||||
@@ -359,7 +361,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.29.7",
|
"@babel/helper-string-parser": "^7.29.7",
|
||||||
@@ -1196,7 +1198,7 @@
|
|||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
@@ -1207,7 +1209,7 @@
|
|||||||
"version": "2.3.5",
|
"version": "2.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/gen-mapping": "^0.3.5",
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
@@ -1218,7 +1220,7 @@
|
|||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -1228,14 +1230,14 @@
|
|||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/trace-mapping": {
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
"version": "0.3.31",
|
"version": "0.3.31",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/resolve-uri": "^3.1.0",
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
@@ -2973,6 +2975,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -3037,14 +3046,12 @@
|
|||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.31",
|
"version": "18.3.31",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
@@ -3055,7 +3062,6 @@
|
|||||||
"version": "18.3.7",
|
"version": "18.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
@@ -3442,6 +3448,142 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/are-we-there-yet": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz",
|
"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",
|
"version": "2.10.42",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
|
||||||
"integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==",
|
"integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"baseline-browser-mapping": "dist/cli.cjs"
|
"baseline-browser-mapping": "dist/cli.cjs"
|
||||||
@@ -3640,7 +3782,7 @@
|
|||||||
"version": "4.28.4",
|
"version": "4.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
|
||||||
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
|
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -3845,7 +3987,7 @@
|
|||||||
"version": "1.0.30001800",
|
"version": "1.0.30001800",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
|
||||||
"integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==",
|
"integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -4093,6 +4235,23 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -4187,7 +4346,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/core-util-is": {
|
"node_modules/core-util-is": {
|
||||||
@@ -4195,8 +4354,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
||||||
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
|
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/crc": {
|
"node_modules/crc": {
|
||||||
"version": "3.8.0",
|
"version": "3.8.0",
|
||||||
@@ -4209,6 +4367,35 @@
|
|||||||
"buffer": "^5.1.0"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -4228,7 +4415,6 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
@@ -4383,6 +4569,12 @@
|
|||||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/dir-compare": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
|
||||||
@@ -4581,6 +4773,20 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/electron-publish": {
|
||||||
"version": "25.1.7",
|
"version": "25.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz",
|
||||||
@@ -4601,7 +4807,7 @@
|
|||||||
"version": "1.5.387",
|
"version": "1.5.387",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz",
|
||||||
"integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==",
|
"integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/electron-vite": {
|
"node_modules/electron-vite": {
|
||||||
@@ -4783,7 +4989,7 @@
|
|||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -4997,6 +5203,14 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fs-constants": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/fs-extra": {
|
"node_modules/fs-extra": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||||
@@ -5082,7 +5296,7 @@
|
|||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
|
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -5654,6 +5868,14 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/isbinaryfile": {
|
||||||
"version": "5.0.7",
|
"version": "5.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz",
|
||||||
@@ -5770,7 +5992,7 @@
|
|||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||||
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"jsesc": "bin/jsesc"
|
"jsesc": "bin/jsesc"
|
||||||
@@ -5803,7 +6025,7 @@
|
|||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"json5": "lib/cli.js"
|
"json5": "lib/cli.js"
|
||||||
@@ -5841,6 +6063,56 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/linkifyjs": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||||
@@ -5854,6 +6126,46 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/log-symbols": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
|
||||||
@@ -5903,7 +6215,7 @@
|
|||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
@@ -6376,7 +6688,7 @@
|
|||||||
"version": "2.0.50",
|
"version": "2.0.50",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
|
||||||
"integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
|
"integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -6398,6 +6710,17 @@
|
|||||||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
"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": {
|
"node_modules/normalize-url": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
|
||||||
@@ -6636,7 +6959,7 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
@@ -6728,6 +7051,14 @@
|
|||||||
"node": "^10 || ^12 || >=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": {
|
"node_modules/progress": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||||
@@ -7087,6 +7418,50 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readdir-glob": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/readdir-glob/-/readdir-glob-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"minimatch": "^5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readdir-glob/node_modules/balanced-match": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/readdir-glob/node_modules/brace-expansion": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readdir-glob/node_modules/minimatch": {
|
||||||
|
"version": "5.1.9",
|
||||||
|
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.9.tgz",
|
||||||
|
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -7723,6 +8098,24 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/tar/node_modules/minipass": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
|
||||||
@@ -7865,7 +8258,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -7921,7 +8314,7 @@
|
|||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -8359,7 +8752,7 @@
|
|||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/yargs": {
|
"node_modules/yargs": {
|
||||||
@@ -8414,6 +8807,102 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/zustand": {
|
||||||
"version": "5.0.14",
|
"version": "5.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bilin",
|
"name": "bilin",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "笔临 - 长篇创作智能协作平台",
|
"description": "笔临 - 长篇创作智能协作平台",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"@tiptap/extension-underline": "^3.27.1",
|
"@tiptap/extension-underline": "^3.27.1",
|
||||||
"@tiptap/react": "^3.27.1",
|
"@tiptap/react": "^3.27.1",
|
||||||
"@tiptap/starter-kit": "^3.27.1",
|
"@tiptap/starter-kit": "^3.27.1",
|
||||||
|
"diff-match-patch": "^1.0.5",
|
||||||
"electron": "^36.9.0",
|
"electron": "^36.9.0",
|
||||||
"i18next": "^24.2.3",
|
"i18next": "^24.2.3",
|
||||||
"jotai": "^2.12.1",
|
"jotai": "^2.12.1",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.51.0",
|
"@playwright/test": "^1.51.0",
|
||||||
|
"@types/diff-match-patch": "^1.0.36",
|
||||||
"@types/node": "^22.13.0",
|
"@types/node": "^22.13.0",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"toast.bookCreated": "Book created",
|
"toast.bookCreated": "Book created",
|
||||||
"toast.saveFailed": "Save failed, please retry",
|
"toast.saveFailed": "Save failed, please retry",
|
||||||
"toast.enterBookName": "Please enter a book title",
|
"toast.enterBookName": "Please enter a book title",
|
||||||
|
"toast.noBookOpen": "No book open",
|
||||||
"shortcuts.saveChapter": "Save chapter",
|
"shortcuts.saveChapter": "Save chapter",
|
||||||
"shortcuts.newChapter": "New chapter",
|
"shortcuts.newChapter": "New chapter",
|
||||||
"shortcuts.focusMode": "Focus mode",
|
"shortcuts.focusMode": "Focus mode",
|
||||||
@@ -83,5 +84,60 @@
|
|||||||
"shortcuts.recordHint": "Press a new key combination (Esc to cancel)",
|
"shortcuts.recordHint": "Press a new key combination (Esc to cancel)",
|
||||||
"shortcuts.recording": "Recording…",
|
"shortcuts.recording": "Recording…",
|
||||||
"shortcuts.conflict": "This shortcut is already assigned",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"toast.bookCreated": "书籍已创建",
|
"toast.bookCreated": "书籍已创建",
|
||||||
"toast.saveFailed": "保存失败,请重试",
|
"toast.saveFailed": "保存失败,请重试",
|
||||||
"toast.enterBookName": "请输入书名",
|
"toast.enterBookName": "请输入书名",
|
||||||
|
"toast.noBookOpen": "未打开书籍",
|
||||||
"shortcuts.saveChapter": "保存章节",
|
"shortcuts.saveChapter": "保存章节",
|
||||||
"shortcuts.newChapter": "新建章节",
|
"shortcuts.newChapter": "新建章节",
|
||||||
"shortcuts.focusMode": "专注模式",
|
"shortcuts.focusMode": "专注模式",
|
||||||
@@ -83,5 +84,60 @@
|
|||||||
"shortcuts.recordHint": "请按下新的快捷键组合(Esc 取消)",
|
"shortcuts.recordHint": "请按下新的快捷键组合(Esc 取消)",
|
||||||
"shortcuts.recording": "录制中…",
|
"shortcuts.recording": "录制中…",
|
||||||
"shortcuts.conflict": "该快捷键已被其他功能占用",
|
"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 命名建议"
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-7
@@ -1,7 +1,25 @@
|
|||||||
import type { SqliteDb } from './types'
|
import type { SqliteDb } from './types'
|
||||||
import schemaV1 from './schema-v1.sql?raw'
|
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 {
|
export function migrate(db: SqliteDb): void {
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS schema_version (
|
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 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
|
if (current >= CURRENT_VERSION) return
|
||||||
|
|
||||||
db.exec(schemaV1)
|
if (current < 1) {
|
||||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(
|
db.exec(schemaV1)
|
||||||
CURRENT_VERSION,
|
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(1, 'initial schema')
|
||||||
'initial schema'
|
current = 1
|
||||||
)
|
}
|
||||||
|
|
||||||
|
if (current < 2) {
|
||||||
|
applyV2(db)
|
||||||
|
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(2, 'P2 schema')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import type { Bookmark, LandmarkType } from '../../../shared/types'
|
||||||
|
import type { SqliteDb } from '../types'
|
||||||
|
|
||||||
|
function mapRow(row: Record<string, unknown>): 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<string, unknown>
|
||||||
|
| 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<string, unknown>[]).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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown>): 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<string, unknown>
|
||||||
|
| undefined
|
||||||
|
return row ? mapRow(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): InspirationEntry[] {
|
||||||
|
return (
|
||||||
|
this.db.prepare('SELECT * FROM inspiration ORDER BY updated_at DESC').all() as Record<string, unknown>[]
|
||||||
|
).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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import type { OutlineItem, OutlineStatus } from '../../../shared/types'
|
||||||
|
import type { SqliteDb } from '../types'
|
||||||
|
|
||||||
|
function mapRow(row: Record<string, unknown>): 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<string | null, OutlineItem[]>()
|
||||||
|
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<string, unknown>
|
||||||
|
| undefined
|
||||||
|
return row ? mapRow(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
listFlat(): OutlineItem[] {
|
||||||
|
return (
|
||||||
|
this.db.prepare('SELECT * FROM outline_items ORDER BY sort_order').all() as Record<string, unknown>[]
|
||||||
|
).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<string, unknown>[]
|
||||||
|
).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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import type { SettingEntry, SettingType } from '../../../shared/types'
|
||||||
|
import type { SqliteDb } from '../types'
|
||||||
|
|
||||||
|
function mapRow(row: Record<string, unknown>, chapterIds: string[]): SettingEntry {
|
||||||
|
let properties: Record<string, unknown> = {}
|
||||||
|
try {
|
||||||
|
properties = JSON.parse((row.properties as string) || '{}') as Record<string, unknown>
|
||||||
|
} 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<string, unknown> = {}): 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<string, unknown>
|
||||||
|
| 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<string, unknown>[])
|
||||||
|
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<string, unknown> }>
|
||||||
|
): 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)!
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import type { Snapshot, SnapshotType } from '../../../shared/types'
|
||||||
|
import type { SqliteDb } from '../types'
|
||||||
|
|
||||||
|
function mapRow(row: Record<string, unknown>): 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<string, unknown>
|
||||||
|
| 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<string, unknown>[]
|
||||||
|
).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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
);
|
||||||
+6
-1
@@ -2,7 +2,7 @@ import { app, BrowserWindow, ipcMain } from 'electron'
|
|||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { IPC } from '../shared/ipc-channels'
|
import { IPC } from '../shared/ipc-channels'
|
||||||
import { getShortcutManager, registerIpc } from './ipc/register'
|
import { getShortcutManager, getSnapshotService, registerIpc } from './ipc/register'
|
||||||
import { closeAllBookDbs } from './db/connection'
|
import { closeAllBookDbs } from './db/connection'
|
||||||
|
|
||||||
if (process.env.BILIN_E2E === '1' && process.env.BILIN_E2E_USER_DATA) {
|
if (process.env.BILIN_E2E === '1' && process.env.BILIN_E2E_USER_DATA) {
|
||||||
@@ -66,6 +66,7 @@ app.whenReady().then(() => {
|
|||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
getShortcutManager()?.unregisterAll()
|
getShortcutManager()?.unregisterAll()
|
||||||
|
getSnapshotService()?.stopAll()
|
||||||
closeAllBookDbs()
|
closeAllBookDbs()
|
||||||
if (process.platform !== 'darwin') app.quit()
|
if (process.platform !== 'darwin') app.quit()
|
||||||
})
|
})
|
||||||
@@ -74,6 +75,10 @@ app.on('activate', () => {
|
|||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
getSnapshotService()?.stopAll()
|
||||||
|
})
|
||||||
|
|
||||||
app.on('will-quit', () => {
|
app.on('will-quit', () => {
|
||||||
getShortcutManager()?.unregisterAll()
|
getShortcutManager()?.unregisterAll()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { ipcMain } from 'electron'
|
|||||||
import { IPC } from '../../../shared/ipc-channels'
|
import { IPC } from '../../../shared/ipc-channels'
|
||||||
import type { ChapterStatus } from '../../../shared/types'
|
import type { ChapterStatus } from '../../../shared/types'
|
||||||
import { BookRegistryService } from '../../services/book-registry'
|
import { BookRegistryService } from '../../services/book-registry'
|
||||||
|
import { ftsSync } from '../../services/fts-sync.service'
|
||||||
import { wrap } from '../result'
|
import { wrap } from '../result'
|
||||||
|
|
||||||
export function registerChapterHandlers(registry: BookRegistryService): void {
|
export function registerChapterHandlers(registry: BookRegistryService): void {
|
||||||
@@ -40,7 +41,9 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
|
|||||||
wrap(() => {
|
wrap(() => {
|
||||||
const repo = registry.getChapterRepo(bookId)
|
const repo = registry.getChapterRepo(bookId)
|
||||||
const sortOrder = repo.nextSortOrder(volumeId)
|
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,
|
status,
|
||||||
cursorOffset
|
cursorOffset
|
||||||
})
|
})
|
||||||
|
ftsSync.upsert(
|
||||||
|
registry.getDb(bookId),
|
||||||
|
'chapter',
|
||||||
|
chapter.id,
|
||||||
|
chapter.title,
|
||||||
|
chapter.content
|
||||||
|
)
|
||||||
registry.updateMeta(bookId, { lastChapterId: chapterId })
|
registry.updateMeta(bookId, { lastChapterId: chapterId })
|
||||||
return chapter
|
return chapter
|
||||||
})
|
})
|
||||||
@@ -91,6 +101,7 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
|
|||||||
(_event, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
|
(_event, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
|
||||||
wrap(() => {
|
wrap(() => {
|
||||||
registry.getChapterRepo(bookId).delete(chapterId)
|
registry.getChapterRepo(bookId).delete(chapterId)
|
||||||
|
ftsSync.remove(registry.getDb(bookId), 'chapter', chapterId)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string, unknown> }>
|
||||||
|
}
|
||||||
|
) =>
|
||||||
|
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))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,13 +2,21 @@ import { join } from 'path'
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { GlobalSettingsService } from '../services/global-settings'
|
import { GlobalSettingsService } from '../services/global-settings'
|
||||||
import { BookRegistryService } from '../services/book-registry'
|
import { BookRegistryService } from '../services/book-registry'
|
||||||
|
import { SnapshotService } from '../services/snapshot.service'
|
||||||
import { ShortcutManager } from '../shortcuts/manager'
|
import { ShortcutManager } from '../shortcuts/manager'
|
||||||
import { registerSettingsHandlers } from './handlers/settings.handler'
|
import { registerSettingsHandlers } from './handlers/settings.handler'
|
||||||
import { registerBookHandlers } from './handlers/book.handler'
|
import { registerBookHandlers } from './handlers/book.handler'
|
||||||
import { registerChapterHandlers } from './handlers/chapter.handler'
|
import { registerChapterHandlers } from './handlers/chapter.handler'
|
||||||
import { registerShortcutHandlers } from './handlers/shortcut.handler'
|
import { registerShortcutHandlers } from './handlers/shortcut.handler'
|
||||||
|
import { registerOutlineHandlers } from './handlers/outline.handler'
|
||||||
|
import { 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 shortcutManager: ShortcutManager | null = null
|
||||||
|
let snapshotService: SnapshotService | null = null
|
||||||
|
|
||||||
export function registerIpc(): { settings: GlobalSettingsService; books: BookRegistryService; shortcuts: ShortcutManager } {
|
export function registerIpc(): { settings: GlobalSettingsService; books: BookRegistryService; shortcuts: ShortcutManager } {
|
||||||
const userData = app.getPath('userData')
|
const userData = app.getPath('userData')
|
||||||
@@ -16,11 +24,18 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
|
|||||||
const settings = new GlobalSettingsService(userData)
|
const settings = new GlobalSettingsService(userData)
|
||||||
const books = new BookRegistryService(userData)
|
const books = new BookRegistryService(userData)
|
||||||
shortcutManager = new ShortcutManager(settings)
|
shortcutManager = new ShortcutManager(settings)
|
||||||
|
snapshotService = new SnapshotService(() => settings.get())
|
||||||
|
|
||||||
registerSettingsHandlers(settings)
|
registerSettingsHandlers(settings)
|
||||||
registerBookHandlers(books)
|
registerBookHandlers(books)
|
||||||
registerChapterHandlers(books)
|
registerChapterHandlers(books)
|
||||||
registerShortcutHandlers(shortcutManager)
|
registerShortcutHandlers(shortcutManager)
|
||||||
|
registerOutlineHandlers(books)
|
||||||
|
registerSettingHandlers(books)
|
||||||
|
registerInspirationHandlers(books)
|
||||||
|
registerSnapshotHandlers(books, snapshotService!)
|
||||||
|
registerBookmarkHandlers(books)
|
||||||
|
registerSearchHandlers(books, settings)
|
||||||
|
|
||||||
return { settings, books, shortcuts: shortcutManager }
|
return { settings, books, shortcuts: shortcutManager }
|
||||||
}
|
}
|
||||||
@@ -28,3 +43,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
|
|||||||
export function getShortcutManager(): ShortcutManager | null {
|
export function getShortcutManager(): ShortcutManager | null {
|
||||||
return shortcutManager
|
return shortcutManager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSnapshotService(): SnapshotService | null {
|
||||||
|
return snapshotService
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { randomUUID } from 'crypto'
|
|||||||
import { openBookDb } from '../db/connection'
|
import { openBookDb } from '../db/connection'
|
||||||
import { VolumeRepository } from '../db/repositories/volume.repo'
|
import { VolumeRepository } from '../db/repositories/volume.repo'
|
||||||
import { ChapterRepository } from '../db/repositories/chapter.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'
|
import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume } from '../../shared/types'
|
||||||
|
|
||||||
interface RegistryFile {
|
interface RegistryFile {
|
||||||
@@ -99,9 +104,19 @@ export class BookRegistryService {
|
|||||||
const db = openBookDb(join(this.userDataDir, meta.dbPath))
|
const db = openBookDb(join(this.userDataDir, meta.dbPath))
|
||||||
const volumes = new VolumeRepository(db).list()
|
const volumes = new VolumeRepository(db).list()
|
||||||
const chapters = new ChapterRepository(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() })
|
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) {
|
getDb(bookId: string) {
|
||||||
@@ -117,6 +132,26 @@ export class BookRegistryService {
|
|||||||
getChapterRepo(bookId: string): ChapterRepository {
|
getChapterRepo(bookId: string): ChapterRepository {
|
||||||
return new ChapterRepository(this.getDb(bookId))
|
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[]): {
|
export function aggregateWordCounts(chapters: Chapter[], volumes: Volume[]): {
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -12,7 +12,11 @@ function defaults(): GlobalSettings {
|
|||||||
theme: 'default',
|
theme: 'default',
|
||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
dailyWordGoal: 0,
|
dailyWordGoal: 0,
|
||||||
shortcuts: { ...DEFAULT_SHORTCUTS }
|
shortcuts: { ...DEFAULT_SHORTCUTS },
|
||||||
|
snapshotIntervalMs: 300_000,
|
||||||
|
snapshotMaxAuto: 20,
|
||||||
|
snapshotMaxPersist: 3,
|
||||||
|
searchHistory: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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<typeof setInterval>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SnapshotService {
|
||||||
|
private sessions = new Map<string, AutoSaveSession>()
|
||||||
|
|
||||||
|
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<string, SnapshotRepository>): 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
|
export function stripHtml(text: string): string {
|
||||||
|
return text.replace(/<[^>]+>/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
export function countWords(text: string): number {
|
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 cjk = stripped.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g)
|
||||||
const latin = stripped.match(/[a-zA-Z0-9]+/g)
|
const latin = stripped.match(/[a-zA-Z0-9]+/g)
|
||||||
return (cjk?.length ?? 0) + (latin?.length ?? 0)
|
return (cjk?.length ?? 0) + (latin?.length ?? 0)
|
||||||
|
|||||||
@@ -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<string, number>()
|
||||||
|
|
||||||
|
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()
|
||||||
+163
-1
@@ -2,14 +2,25 @@ import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'
|
|||||||
import { IPC } from '../shared/ipc-channels'
|
import { IPC } from '../shared/ipc-channels'
|
||||||
import type {
|
import type {
|
||||||
ActionId,
|
ActionId,
|
||||||
|
Bookmark,
|
||||||
BookMeta,
|
BookMeta,
|
||||||
BookOpenResult,
|
BookOpenResult,
|
||||||
Chapter,
|
Chapter,
|
||||||
CreateBookParams,
|
CreateBookParams,
|
||||||
GlobalSettings,
|
GlobalSettings,
|
||||||
|
InspirationEntry,
|
||||||
IpcResult,
|
IpcResult,
|
||||||
|
LandmarkType,
|
||||||
|
OutlineItem,
|
||||||
|
OutlineStatus,
|
||||||
|
SearchResult,
|
||||||
|
SettingEntry,
|
||||||
|
SettingType,
|
||||||
|
Snapshot,
|
||||||
|
SnapshotType,
|
||||||
UpdateChapterParams,
|
UpdateChapterParams,
|
||||||
Volume
|
Volume,
|
||||||
|
WordFreqResult
|
||||||
} from '../shared/types'
|
} from '../shared/types'
|
||||||
|
|
||||||
const electronAPI = {
|
const electronAPI = {
|
||||||
@@ -58,6 +69,157 @@ const electronAPI = {
|
|||||||
delete: (bookId: string, chapterId: string): Promise<IpcResult<void>> =>
|
delete: (bookId: string, chapterId: string): Promise<IpcResult<void>> =>
|
||||||
ipcRenderer.invoke(IPC.CHAPTER_DELETE, { bookId, chapterId })
|
ipcRenderer.invoke(IPC.CHAPTER_DELETE, { bookId, chapterId })
|
||||||
},
|
},
|
||||||
|
outline: {
|
||||||
|
list: (bookId: string): Promise<IpcResult<OutlineItem[]>> =>
|
||||||
|
ipcRenderer.invoke(IPC.OUTLINE_LIST, { bookId }),
|
||||||
|
create: (
|
||||||
|
bookId: string,
|
||||||
|
title: string,
|
||||||
|
parentId?: string | null,
|
||||||
|
sortOrder?: number
|
||||||
|
): Promise<IpcResult<OutlineItem>> =>
|
||||||
|
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<IpcResult<OutlineItem>> =>
|
||||||
|
ipcRenderer.invoke(IPC.OUTLINE_UPDATE, { bookId, id, patch }),
|
||||||
|
delete: (bookId: string, id: string): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.OUTLINE_DELETE, { bookId, id }),
|
||||||
|
move: (
|
||||||
|
bookId: string,
|
||||||
|
id: string,
|
||||||
|
parentId: string | null,
|
||||||
|
sortOrder: number
|
||||||
|
): Promise<IpcResult<OutlineItem>> =>
|
||||||
|
ipcRenderer.invoke(IPC.OUTLINE_MOVE, { bookId, id, parentId, sortOrder })
|
||||||
|
},
|
||||||
|
setting: {
|
||||||
|
list: (bookId: string, type?: SettingType): Promise<IpcResult<SettingEntry[]>> =>
|
||||||
|
ipcRenderer.invoke(IPC.SETTING_LIST, { bookId, type }),
|
||||||
|
create: (bookId: string, type: SettingType, name: string): Promise<IpcResult<SettingEntry>> =>
|
||||||
|
ipcRenderer.invoke(IPC.SETTING_CREATE, { bookId, type, name }),
|
||||||
|
update: (
|
||||||
|
bookId: string,
|
||||||
|
id: string,
|
||||||
|
patch: Partial<{ name: string; description: string; type: SettingType; properties: Record<string, unknown> }>
|
||||||
|
): Promise<IpcResult<SettingEntry>> =>
|
||||||
|
ipcRenderer.invoke(IPC.SETTING_UPDATE, { bookId, id, patch }),
|
||||||
|
delete: (bookId: string, id: string): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.SETTING_DELETE, { bookId, id }),
|
||||||
|
syncRefs: (bookId: string, id: string, chapterIds: string[]): Promise<IpcResult<SettingEntry>> =>
|
||||||
|
ipcRenderer.invoke(IPC.SETTING_SYNC_REFS, { bookId, id, chapterIds })
|
||||||
|
},
|
||||||
|
inspiration: {
|
||||||
|
list: (bookId: string): Promise<IpcResult<InspirationEntry[]>> =>
|
||||||
|
ipcRenderer.invoke(IPC.INSPIRATION_LIST, { bookId }),
|
||||||
|
create: (
|
||||||
|
bookId: string,
|
||||||
|
title?: string,
|
||||||
|
content?: string,
|
||||||
|
tags?: string[]
|
||||||
|
): Promise<IpcResult<InspirationEntry>> =>
|
||||||
|
ipcRenderer.invoke(IPC.INSPIRATION_CREATE, { bookId, title, content, tags }),
|
||||||
|
update: (
|
||||||
|
bookId: string,
|
||||||
|
id: string,
|
||||||
|
patch: Partial<{ title: string; content: string; tags: string[] }>
|
||||||
|
): Promise<IpcResult<InspirationEntry>> =>
|
||||||
|
ipcRenderer.invoke(IPC.INSPIRATION_UPDATE, { bookId, id, patch }),
|
||||||
|
delete: (bookId: string, id: string): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.INSPIRATION_DELETE, { bookId, id }),
|
||||||
|
convert: (
|
||||||
|
bookId: string,
|
||||||
|
id: string,
|
||||||
|
targetKind: 'outline' | 'setting',
|
||||||
|
settingType?: SettingType
|
||||||
|
): Promise<IpcResult<OutlineItem | SettingEntry>> =>
|
||||||
|
ipcRenderer.invoke(IPC.INSPIRATION_CONVERT, { bookId, id, targetKind, settingType })
|
||||||
|
},
|
||||||
|
snapshot: {
|
||||||
|
list: (bookId: string, chapterId: string): Promise<IpcResult<Snapshot[]>> =>
|
||||||
|
ipcRenderer.invoke(IPC.SNAPSHOT_LIST, { bookId, chapterId }),
|
||||||
|
create: (
|
||||||
|
bookId: string,
|
||||||
|
chapterId: string,
|
||||||
|
type: SnapshotType,
|
||||||
|
name?: string,
|
||||||
|
content?: string
|
||||||
|
): Promise<IpcResult<Snapshot>> =>
|
||||||
|
ipcRenderer.invoke(IPC.SNAPSHOT_CREATE, { bookId, chapterId, type, name, content }),
|
||||||
|
restore: (
|
||||||
|
bookId: string,
|
||||||
|
chapterId: string,
|
||||||
|
snapshotId: string
|
||||||
|
): Promise<IpcResult<Chapter>> =>
|
||||||
|
ipcRenderer.invoke(IPC.SNAPSHOT_RESTORE, { bookId, chapterId, snapshotId }),
|
||||||
|
delete: (bookId: string, snapshotId: string): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.SNAPSHOT_DELETE, { bookId, snapshotId }),
|
||||||
|
startAutoSave: (bookId: string, chapterId: string): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.SNAPSHOT_START_AUTO, { bookId, chapterId }),
|
||||||
|
stopAutoSave: (bookId: string, chapterId: string): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.SNAPSHOT_STOP_AUTO, { bookId, chapterId })
|
||||||
|
},
|
||||||
|
bookmark: {
|
||||||
|
list: (
|
||||||
|
bookId: string,
|
||||||
|
chapterId?: string,
|
||||||
|
resolved?: boolean
|
||||||
|
): Promise<IpcResult<Bookmark[]>> =>
|
||||||
|
ipcRenderer.invoke(IPC.BOOKMARK_LIST, { bookId, chapterId, resolved }),
|
||||||
|
create: (
|
||||||
|
bookId: string,
|
||||||
|
chapterId: string,
|
||||||
|
position: number,
|
||||||
|
label: string,
|
||||||
|
landmarkType?: LandmarkType
|
||||||
|
): Promise<IpcResult<Bookmark>> =>
|
||||||
|
ipcRenderer.invoke(IPC.BOOKMARK_CREATE, { bookId, chapterId, position, label, landmarkType }),
|
||||||
|
update: (
|
||||||
|
bookId: string,
|
||||||
|
id: string,
|
||||||
|
patch: Partial<{ label: string; position: number; landmarkType: LandmarkType }>
|
||||||
|
): Promise<IpcResult<Bookmark>> =>
|
||||||
|
ipcRenderer.invoke(IPC.BOOKMARK_UPDATE, { bookId, id, patch }),
|
||||||
|
resolve: (bookId: string, id: string, resolved: boolean): Promise<IpcResult<Bookmark>> =>
|
||||||
|
ipcRenderer.invoke(IPC.BOOKMARK_RESOLVE, { bookId, id, resolved }),
|
||||||
|
delete: (bookId: string, id: string): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.BOOKMARK_DELETE, { bookId, id })
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
query: (
|
||||||
|
bookId: string,
|
||||||
|
query: string,
|
||||||
|
options?: { regex?: boolean; caseSensitive?: boolean; matchWholeWord?: boolean }
|
||||||
|
): Promise<IpcResult<SearchResult[]>> =>
|
||||||
|
ipcRenderer.invoke(IPC.SEARCH_QUERY, { bookId, query, options }),
|
||||||
|
replace: (
|
||||||
|
bookId: string,
|
||||||
|
query: string,
|
||||||
|
replacement: string,
|
||||||
|
dryRun: boolean,
|
||||||
|
options?: { regex?: boolean; caseSensitive?: boolean }
|
||||||
|
): Promise<IpcResult<{ count: number; previews: SearchResult[] }>> =>
|
||||||
|
ipcRenderer.invoke(IPC.SEARCH_REPLACE, { bookId, query, replacement, dryRun, options }),
|
||||||
|
getHistory: (): Promise<IpcResult<string[]>> => ipcRenderer.invoke(IPC.SEARCH_GET_HISTORY),
|
||||||
|
saveHistory: (query: string): Promise<IpcResult<string[]>> =>
|
||||||
|
ipcRenderer.invoke(IPC.SEARCH_SAVE_HISTORY, { query })
|
||||||
|
},
|
||||||
|
wordfreq: {
|
||||||
|
analyze: (
|
||||||
|
bookId: string,
|
||||||
|
scope: { kind: 'book' | 'volume' | 'chapter'; chapterId?: string; volumeId?: string }
|
||||||
|
): Promise<IpcResult<WordFreqResult>> =>
|
||||||
|
ipcRenderer.invoke(IPC.WORDFREQ_ANALYZE, { bookId, scope })
|
||||||
|
},
|
||||||
shortcut: {
|
shortcut: {
|
||||||
getAll: (): Promise<IpcResult<Record<ActionId, string>>> =>
|
getAll: (): Promise<IpcResult<Record<ActionId, string>>> =>
|
||||||
ipcRenderer.invoke(IPC.SHORTCUT_GET_ALL),
|
ipcRenderer.invoke(IPC.SHORTCUT_GET_ALL),
|
||||||
|
|||||||
+62
-1
@@ -14,7 +14,12 @@ import { useAppStore } from '@renderer/stores/useAppStore'
|
|||||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||||
import { useTabStore } from '@renderer/stores/useTabStore'
|
import { useTabStore } from '@renderer/stores/useTabStore'
|
||||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
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 { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||||
|
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
|
||||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
function AppInner(): React.JSX.Element {
|
function AppInner(): React.JSX.Element {
|
||||||
@@ -40,11 +45,35 @@ function AppInner(): React.JSX.Element {
|
|||||||
if (loaded && !onboardingCompleted) setShowOnboarding(true)
|
if (loaded && !onboardingCompleted) setShowOnboarding(true)
|
||||||
}, [loaded, onboardingCompleted])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const unsub = window.electronAPI.onShortcutTriggered(async (action) => {
|
const unsub = window.electronAPI.onShortcutTriggered(async (action) => {
|
||||||
if (action === 'saveChapter') {
|
if (action === 'saveChapter') {
|
||||||
try {
|
try {
|
||||||
await flushEditorSave()
|
await flushEditorSave(true)
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t('toast.saveFailed'))
|
showToast(t('toast.saveFailed'))
|
||||||
}
|
}
|
||||||
@@ -75,6 +104,37 @@ function AppInner(): React.JSX.Element {
|
|||||||
} else setView('home')
|
} else setView('home')
|
||||||
return
|
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<HTMLButtonElement>('[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'))
|
showToast(t('feature.comingSoon'))
|
||||||
})
|
})
|
||||||
return unsub
|
return unsub
|
||||||
@@ -93,6 +153,7 @@ function AppInner(): React.JSX.Element {
|
|||||||
{view === 'settings' && <SettingsPage />}
|
{view === 'settings' && <SettingsPage />}
|
||||||
</div>
|
</div>
|
||||||
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
|
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
|
||||||
|
<SearchModal />
|
||||||
{toast && <div className="toast">{toast}</div>}
|
{toast && <div className="toast">{toast}</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,145 +2,353 @@ import { useEditor, EditorContent } from '@tiptap/react'
|
|||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import Underline from '@tiptap/extension-underline'
|
import Underline from '@tiptap/extension-underline'
|
||||||
import Placeholder from '@tiptap/extension-placeholder'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import { useSetAtom } from 'jotai'
|
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 { editorDirtyAtom, editorSavingAtom, lastSavedAtAtom, wordCountAtom } from '@renderer/atoms/editorAtoms'
|
||||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { flushEditSession, registerEditorFlush } from '@renderer/stores/useEditStore'
|
||||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
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 {
|
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 cjk = stripped.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g)
|
||||||
const latin = stripped.match(/[a-zA-Z0-9]+/g)
|
const latin = stripped.match(/[a-zA-Z0-9]+/g)
|
||||||
return (cjk?.length ?? 0) + (latin?.length ?? 0)
|
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 {
|
interface TipTapEditorProps {
|
||||||
chapter: Chapter | null
|
target: EditTarget | null
|
||||||
bookId: string | 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 { t, i18n } = useTranslation()
|
||||||
const setDirty = useSetAtom(editorDirtyAtom)
|
const setDirty = useSetAtom(editorDirtyAtom)
|
||||||
const setSaving = useSetAtom(editorSavingAtom)
|
const setSaving = useSetAtom(editorSavingAtom)
|
||||||
const setLastSaved = useSetAtom(lastSavedAtAtom)
|
const setLastSaved = useSetAtom(lastSavedAtAtom)
|
||||||
const setWordCount = useSetAtom(wordCountAtom)
|
const setWordCount = useSetAtom(wordCountAtom)
|
||||||
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
|
|
||||||
const chapters = useBookStore((s) => s.chapters)
|
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<ReturnType<typeof setTimeout>>()
|
const timerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||||
const chapterIdRef = useRef<string | null>(null)
|
const targetKeyRef = useRef<string | null>(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({
|
const extensions = useMemo(
|
||||||
extensions: [
|
() =>
|
||||||
StarterKit,
|
isChapter
|
||||||
Underline,
|
? [
|
||||||
Placeholder.configure({ placeholder: t('editor.placeholder') })
|
StarterKit,
|
||||||
],
|
Underline,
|
||||||
content: chapter?.content ?? '',
|
LandmarkMark,
|
||||||
onUpdate: ({ editor: ed }) => {
|
SettingMention,
|
||||||
setDirty(true)
|
Placeholder.configure({ placeholder: t('editor.placeholder') })
|
||||||
clearTimeout(timerRef.current)
|
]
|
||||||
timerRef.current = setTimeout(() => {
|
: [simplifiedKit, Placeholder.configure({ placeholder: t('editor.placeholder') })],
|
||||||
void (window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave?.()
|
[isChapter, t]
|
||||||
}, 2000)
|
)
|
||||||
const text = ed.getText()
|
|
||||||
const volId = chapter?.volumeId
|
const mentionCandidates = useMemo(() => {
|
||||||
const chapterCount = countPlain(text)
|
const q = mentionQuery.toLowerCase()
|
||||||
const volumeTotal = chapters
|
return settings
|
||||||
.filter((c) => c.volumeId === volId)
|
.filter((s) => !q || s.name.toLowerCase().includes(q))
|
||||||
.reduce((sum, c) => sum + (c.id === chapter?.id ? chapterCount : c.wordCount), 0)
|
.slice(0, 8)
|
||||||
const bookTotal = chapters.reduce(
|
}, [settings, mentionQuery])
|
||||||
(sum, c) => sum + (c.id === chapter?.id ? chapterCount : c.wordCount),
|
|
||||||
0
|
const editor = useEditor(
|
||||||
)
|
{
|
||||||
setWordCount({ chapter: chapterCount, volume: volumeTotal, book: bookTotal })
|
extensions,
|
||||||
},
|
content: '',
|
||||||
editorProps: {
|
onUpdate: ({ editor: ed }) => {
|
||||||
handlePaste: (view, event) => {
|
setDirty(true)
|
||||||
if (event.shiftKey) return false
|
clearTimeout(timerRef.current)
|
||||||
const text = event.clipboardData?.getData('text/plain')
|
timerRef.current = setTimeout(() => {
|
||||||
if (text) {
|
void flushEditorSave()
|
||||||
view.dispatch(view.state.tr.insertText(text))
|
}, 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 true
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}, [i18n.language])
|
[target?.kind, target?.id, i18n.language]
|
||||||
|
)
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
const save = useCallback(async () => {
|
||||||
if (!bookId || !chapter || !editor) return
|
if (!bookId || !target || !editor) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const html = editor.getHTML()
|
const html = editor.getHTML()
|
||||||
const text = editor.getText()
|
const text = editor.getText()
|
||||||
const updated = await ipcCall(() =>
|
|
||||||
window.electronAPI.chapter.update({
|
if (target.kind === 'chapter') {
|
||||||
bookId,
|
const updated = await ipcCall(() =>
|
||||||
chapterId: chapter.id,
|
window.electronAPI.chapter.update({
|
||||||
content: html,
|
bookId,
|
||||||
cursorOffset: editor.state.selection.anchor
|
chapterId: target.id,
|
||||||
})
|
content: html,
|
||||||
)
|
cursorOffset: editor.state.selection.anchor
|
||||||
updateChapterLocal(updated)
|
})
|
||||||
|
)
|
||||||
|
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)
|
setDirty(false)
|
||||||
setLastSaved(new Date())
|
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 {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}, [bookId, chapter, chapters, editor, setDirty, setLastSaved, setSaving, setWordCount, updateChapterLocal])
|
}, [
|
||||||
|
bookId,
|
||||||
|
target,
|
||||||
|
editor,
|
||||||
|
chapters,
|
||||||
|
setDirty,
|
||||||
|
setLastSaved,
|
||||||
|
setSaving,
|
||||||
|
setWordCount,
|
||||||
|
updateChapterLocal,
|
||||||
|
updateOutlineLocal,
|
||||||
|
updateSettingLocal,
|
||||||
|
updateInspirationLocal
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
;(window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave = save
|
registerEditorFlush(save)
|
||||||
return () => {
|
return () => {
|
||||||
|
registerEditorFlush(async () => {})
|
||||||
clearTimeout(timerRef.current)
|
clearTimeout(timerRef.current)
|
||||||
delete (window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave
|
|
||||||
}
|
}
|
||||||
}, [save])
|
}, [save])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || !chapter) return
|
if (!editor) return
|
||||||
if (chapterIdRef.current !== chapter.id) {
|
registerInsertLandmark(({ label, landmarkType = 'todo' }) => {
|
||||||
void (async () => {
|
editor
|
||||||
await flushEditorSave()
|
.chain()
|
||||||
chapterIdRef.current = chapter.id
|
.focus()
|
||||||
editor.commands.setContent(chapter.content || '')
|
.setMark('landmark', { label, landmarkType })
|
||||||
const pos = Math.min(chapter.cursorOffset ?? 0, editor.state.doc.content.size)
|
.insertContent(`[TODO: ${label}]`)
|
||||||
editor.commands.focus(pos)
|
.unsetMark('landmark')
|
||||||
setDirty(false)
|
.insertContent(' ')
|
||||||
setWordCount({
|
.run()
|
||||||
chapter: chapter.wordCount,
|
})
|
||||||
volume: chapters.filter((c) => c.volumeId === chapter.volumeId).reduce((s, c) => s + c.wordCount, 0),
|
registerHighlightToken((token) => {
|
||||||
book: chapters.reduce((s, c) => s + c.wordCount, 0)
|
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(
|
||||||
|
`<span data-setting-mention="true" class="setting-mention" data-id="${id}" data-label="${label}">@${label}</span> `
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
setMentionOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
return <div className="placeholder-box">{t('editor.placeholder')}</div>
|
return <div className="placeholder-box">{t('editor.placeholder')}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="editor-content-wrap">
|
<div className="editor-content-wrap">
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
|
{mentionOpen && mentionCandidates.length > 0 && (
|
||||||
|
<div className="mention-dropdown" data-testid="mention-dropdown">
|
||||||
|
{mentionCandidates.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
className="mention-item"
|
||||||
|
data-testid={`mention-item-${s.id}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
applyMention(s.id, s.name)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
@{s.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function flushEditorSave(): Promise<void> {
|
export async function flushEditorSave(persist = false): Promise<void> {
|
||||||
const fn = (window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave
|
persistOnNextSave = persist
|
||||||
if (fn) await fn()
|
await flushEditSession()
|
||||||
|
persistOnNextSave = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void> => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="sidebar-list" data-testid="inspiration-list">
|
||||||
|
{inspirations.length === 0 && (
|
||||||
|
<div className="sidebar-empty">{t('inspiration.empty')}</div>
|
||||||
|
)}
|
||||||
|
{inspirations.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`inspiration-item ${activeId === item.id ? 'active' : ''}`}
|
||||||
|
onClick={() => 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}`}
|
||||||
|
>
|
||||||
|
<div className="inspiration-title">{item.title || t('inspiration.untitled')}</div>
|
||||||
|
<div className="inspiration-preview">
|
||||||
|
{item.content.replace(/<[^>]+>/g, '').slice(0, 40) || t('inspiration.noContent')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<button type="button" data-testid="inspiration-new" onClick={() => void handleCreate()}>
|
||||||
|
+ {t('inspiration.new')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<Bookmark[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !bookId) return
|
||||||
|
void ipcCall(() => window.electronAPI.bookmark.list(bookId)).then(setBookmarks)
|
||||||
|
}, [open, bookId])
|
||||||
|
|
||||||
|
const jumpTo = async (bm: Bookmark): Promise<void> => {
|
||||||
|
setSelectedChapter(bm.chapterId)
|
||||||
|
await switchTarget({ kind: 'chapter', id: bm.chapterId })
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" id="landmarksModal" data-testid="landmarks-modal">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>{t('landmark.title')}</h3>
|
||||||
|
<button type="button" className="modal-close" onClick={onClose}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="search-results">
|
||||||
|
{bookmarks.map((bm) => {
|
||||||
|
const ch = chapters.find((c) => c.id === bm.chapterId)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={bm.id}
|
||||||
|
className="search-result"
|
||||||
|
onClick={() => void jumpTo(bm)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
data-testid={`landmark-item-${bm.id}`}
|
||||||
|
>
|
||||||
|
<div className="sr-type">
|
||||||
|
{bm.landmarkType} · {ch?.title ?? bm.chapterId}
|
||||||
|
</div>
|
||||||
|
<div>{bm.label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{bookmarks.length === 0 && <div className="sidebar-empty">{t('landmark.empty')}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn" onClick={onClose}>
|
||||||
|
{t('dialog.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue } from 'jotai'
|
||||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
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 { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||||
|
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
|
||||||
import { RightPanel } from '@renderer/components/layout/RightPanel'
|
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 {
|
import {
|
||||||
editorDirtyAtom,
|
editorDirtyAtom,
|
||||||
editorSavingAtom,
|
editorSavingAtom,
|
||||||
@@ -12,11 +24,40 @@ import {
|
|||||||
wordCountAtom
|
wordCountAtom
|
||||||
} from '@renderer/atoms/editorAtoms'
|
} 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 {
|
export function EditorLayout(): React.JSX.Element {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const showToast = useAppStore((s) => s.showToast)
|
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 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 {
|
const {
|
||||||
currentBookId,
|
currentBookId,
|
||||||
volumes,
|
volumes,
|
||||||
@@ -27,18 +68,38 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
setActiveVolume,
|
setActiveVolume,
|
||||||
refreshChapters
|
refreshChapters
|
||||||
} = useBookStore()
|
} = useBookStore()
|
||||||
|
const target = useEditStore((s) => s.target)
|
||||||
|
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||||
|
const setTarget = useEditStore((s) => s.setTarget)
|
||||||
|
|
||||||
const dirty = useAtomValue(editorDirtyAtom)
|
const dirty = useAtomValue(editorDirtyAtom)
|
||||||
const saving = useAtomValue(editorSavingAtom)
|
const saving = useAtomValue(editorSavingAtom)
|
||||||
const lastSaved = useAtomValue(lastSavedAtAtom)
|
const lastSaved = useAtomValue(lastSavedAtAtom)
|
||||||
const wordCount = useAtomValue(wordCountAtom)
|
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 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<void> => {
|
const handleSelectChapter = async (chapterId: string): Promise<void> => {
|
||||||
await flushEditorSave()
|
|
||||||
setSelectedChapter(chapterId)
|
setSelectedChapter(chapterId)
|
||||||
|
await switchTarget({ kind: 'chapter', id: chapterId })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNewChapter = async (): Promise<void> => {
|
const handleNewChapter = async (): Promise<void> => {
|
||||||
@@ -49,6 +110,7 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
)
|
)
|
||||||
await refreshChapters()
|
await refreshChapters()
|
||||||
setSelectedChapter(ch.id)
|
setSelectedChapter(ch.id)
|
||||||
|
await switchTarget({ kind: 'chapter', id: ch.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNewVolume = async (): Promise<void> => {
|
const handleNewVolume = async (): Promise<void> => {
|
||||||
@@ -60,10 +122,24 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
setActiveVolume(vol.id)
|
setActiveVolume(vol.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleInsertLandmark = async (): Promise<void> => {
|
||||||
|
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')
|
const saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="editor-layout">
|
<div id="editor-layout" data-book-id={currentBookId ?? ''}>
|
||||||
<div id="left-sidebar">
|
<div id="left-sidebar">
|
||||||
<div className="sidebar-header">{bookMeta?.name ?? '—'}</div>
|
<div className="sidebar-header">{bookMeta?.name ?? '—'}</div>
|
||||||
<div className="sidebar-nav">
|
<div className="sidebar-nav">
|
||||||
@@ -71,8 +147,9 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
<button
|
<button
|
||||||
key={panel}
|
key={panel}
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid={`sidebar-tab-${panel}`}
|
||||||
className={`nav-btn ${activePanel === panel ? 'active' : ''}`}
|
className={`nav-btn ${activePanel === panel ? 'active' : ''}`}
|
||||||
onClick={() => sidebarPanel(panel)}
|
onClick={() => setSidebarPanel(panel)}
|
||||||
>
|
>
|
||||||
{panel === 'chapters' && t('sidebar.chapters')}
|
{panel === 'chapters' && t('sidebar.chapters')}
|
||||||
{panel === 'outline' && t('sidebar.outline')}
|
{panel === 'outline' && t('sidebar.outline')}
|
||||||
@@ -96,7 +173,7 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
.map((ch, idx) => (
|
.map((ch, idx) => (
|
||||||
<div
|
<div
|
||||||
key={ch.id}
|
key={ch.id}
|
||||||
className={`chapter-item ${selectedChapterId === ch.id ? 'active' : ''}`}
|
className={`chapter-item ${target?.kind === 'chapter' && target.id === ch.id ? 'active' : ''}`}
|
||||||
onClick={() => void handleSelectChapter(ch.id)}
|
onClick={() => void handleSelectChapter(ch.id)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && void handleSelectChapter(ch.id)}
|
onKeyDown={(e) => e.key === 'Enter' && void handleSelectChapter(ch.id)}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -109,41 +186,90 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
{activePanel === 'chapters' && (
|
||||||
{activePanel !== 'chapters' && (
|
<div className="sidebar-footer">
|
||||||
<div className="sidebar-panel active">
|
<button type="button" onClick={() => void handleNewChapter()}>
|
||||||
<div className="placeholder-box" style={{ padding: 20 }}>
|
+ {t('editor.newChapter')}
|
||||||
{t('feature.comingSoon')}
|
</button>
|
||||||
|
<button type="button" style={{ marginTop: 6 }} onClick={() => void handleNewVolume()}>
|
||||||
|
+ {t('editor.newVolume')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
<div className="sidebar-footer">
|
<div className={`sidebar-panel ${activePanel === 'outline' ? 'active' : ''}`}>
|
||||||
<button type="button" onClick={() => void handleNewChapter()}>
|
{activePanel === 'outline' && <OutlineTree />}
|
||||||
+ {t('editor.newChapter')}
|
</div>
|
||||||
</button>
|
<div className={`sidebar-panel ${activePanel === 'setting' ? 'active' : ''}`}>
|
||||||
<button type="button" style={{ marginTop: 6 }} onClick={() => void handleNewVolume()}>
|
{activePanel === 'setting' && <SettingList />}
|
||||||
+ {t('editor.newVolume')}
|
</div>
|
||||||
</button>
|
<div className={`sidebar-panel ${activePanel === 'inspiration' ? 'active' : ''}`}>
|
||||||
|
{activePanel === 'inspiration' && <InspirationList />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="editor-area">
|
<div id="editor-area">
|
||||||
<div id="editor-toolbar">
|
{compareMode ? (
|
||||||
<button type="button" className="tool-btn" onClick={() => showToast(t('feature.comingSoon'))} title="引用">
|
<OutlineCompareView />
|
||||||
📎
|
) : (
|
||||||
</button>
|
<>
|
||||||
<span className="editor-label">
|
<div id="editor-toolbar">
|
||||||
{currentChapter ? currentChapter.title : '—'}
|
<button
|
||||||
</span>
|
type="button"
|
||||||
</div>
|
className="tool-btn"
|
||||||
<TipTapEditor chapter={currentChapter} bookId={currentBookId} />
|
title={t('reference.title')}
|
||||||
<div id="editor-statusbar">
|
data-testid="open-reference"
|
||||||
<span>{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}</span>
|
onClick={() => setReferenceOpen(true)}
|
||||||
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
|
>
|
||||||
<span>{t('status.volume')}: {t('editor.words', { count: wordCount.volume })}</span>
|
📎
|
||||||
<span>{t('status.book')}: {t('editor.words', { count: wordCount.book })}</span>
|
</button>
|
||||||
</div>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tool-btn"
|
||||||
|
title={t('version.title')}
|
||||||
|
data-testid="open-version"
|
||||||
|
onClick={() => setVersionModalOpen(true)}
|
||||||
|
>
|
||||||
|
📜
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tool-btn"
|
||||||
|
title={t('landmark.insert')}
|
||||||
|
data-testid="insert-landmark"
|
||||||
|
onClick={() => void handleInsertLandmark()}
|
||||||
|
>
|
||||||
|
📌
|
||||||
|
</button>
|
||||||
|
<span className="editor-label">{documentTitle}</span>
|
||||||
|
</div>
|
||||||
|
<TipTapEditor
|
||||||
|
key={target ? `${target.kind}:${target.id}` : 'none'}
|
||||||
|
target={target}
|
||||||
|
bookId={currentBookId}
|
||||||
|
/>
|
||||||
|
<div id="editor-statusbar">
|
||||||
|
<span>{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}</span>
|
||||||
|
{isChapterTarget ? (
|
||||||
|
<>
|
||||||
|
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
|
||||||
|
<span>{t('status.volume')}: {t('editor.words', { count: wordCount.volume })}</span>
|
||||||
|
<span>{t('status.book')}: {t('editor.words', { count: wordCount.book })}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>{t('editor.words', { count: wordCount.chapter })}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<RightPanel />
|
<ReferencePanel />
|
||||||
|
{!referenceOpen && <RightPanel />}
|
||||||
|
<VersionModal
|
||||||
|
open={versionModalOpen}
|
||||||
|
onClose={() => setVersionModalOpen(false)}
|
||||||
|
chapterId={chapterId}
|
||||||
|
/>
|
||||||
|
<LandmarkModal open={landmarksModalOpen} onClose={() => setLandmarksModalOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
|
import { WordFreqPanel } from '@renderer/components/wordfreq/WordFreqPanel'
|
||||||
|
|
||||||
export function RightPanel(): React.JSX.Element {
|
export function RightPanel(): React.JSX.Element {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -20,6 +21,7 @@ export function RightPanel(): React.JSX.Element {
|
|||||||
key={tab.id}
|
key={tab.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={`panel-tab ${rightPanel === tab.id ? 'active' : ''}`}
|
className={`panel-tab ${rightPanel === tab.id ? 'active' : ''}`}
|
||||||
|
data-testid={`panel-tab-${tab.id}`}
|
||||||
onClick={() => setRightPanel(tab.id)}
|
onClick={() => setRightPanel(tab.id)}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
@@ -30,11 +32,15 @@ export function RightPanel(): React.JSX.Element {
|
|||||||
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8 }}>{t('panel.aiPlaceholder')}</p>
|
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8 }}>{t('panel.aiPlaceholder')}</p>
|
||||||
<input className="ai-input-disabled" disabled placeholder={t('feature.comingSoon')} />
|
<input className="ai-input-disabled" disabled placeholder={t('feature.comingSoon')} />
|
||||||
</div>
|
</div>
|
||||||
{(['knowledge', 'wordfreq', 'book-settings'] as const).map((id) => (
|
<div className={`panel-content ${rightPanel === 'knowledge' ? 'active' : ''}`}>
|
||||||
<div key={id} className={`panel-content ${rightPanel === id ? 'active' : ''}`}>
|
<div className="placeholder-box">{t('feature.comingSoon')}</div>
|
||||||
<div className="placeholder-box">{t('feature.comingSoon')}</div>
|
</div>
|
||||||
</div>
|
<div className={`panel-content ${rightPanel === 'wordfreq' ? 'active' : ''}`}>
|
||||||
))}
|
<WordFreqPanel />
|
||||||
|
</div>
|
||||||
|
<div className={`panel-content ${rightPanel === 'book-settings' ? 'active' : ''}`}>
|
||||||
|
<div className="placeholder-box">{t('feature.comingSoon')}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,10 +54,11 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps): React.J
|
|||||||
<h2>{t('onboarding.penName')}</h2>
|
<h2>{t('onboarding.penName')}</h2>
|
||||||
<input
|
<input
|
||||||
data-testid="onboarding-pen-name"
|
data-testid="onboarding-pen-name"
|
||||||
|
className="form-control form-control--wide"
|
||||||
value={penName}
|
value={penName}
|
||||||
onChange={(e) => setPenName(e.target.value)}
|
onChange={(e) => setPenName(e.target.value)}
|
||||||
placeholder={t('onboarding.penNamePlaceholder')}
|
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 }}
|
||||||
/>
|
/>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 12, fontSize: 12, color: 'var(--text-secondary)' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 12, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||||
<input type="checkbox" checked={createSample} onChange={(e) => setCreateSample(e.target.checked)} />
|
<input type="checkbox" checked={createSample} onChange={(e) => setCreateSample(e.target.checked)} />
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { useOutlineStore, filterOutlines } from '@renderer/stores/useOutlineStore'
|
||||||
|
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||||
|
import { buildOutlineTree, calcDeviation, flattenOutlineTree } from '@renderer/lib/outline-utils'
|
||||||
|
|
||||||
|
export function OutlineCompareView(): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const outlines = useBookStore((s) => s.outlines)
|
||||||
|
const chapters = useBookStore((s) => s.chapters)
|
||||||
|
const filter = useOutlineStore((s) => s.filter)
|
||||||
|
const compareSelectedId = useOutlineStore((s) => s.compareSelectedId)
|
||||||
|
const setCompareSelectedId = useOutlineStore((s) => s.setCompareSelectedId)
|
||||||
|
const setCompareMode = useOutlineStore((s) => s.setCompareMode)
|
||||||
|
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||||
|
|
||||||
|
const filtered = useMemo(() => filterOutlines(outlines, filter), [outlines, filter])
|
||||||
|
const flat = useMemo(() => flattenOutlineTree(buildOutlineTree(filtered)), [filtered])
|
||||||
|
const selected =
|
||||||
|
flat.find((o) => o.id === compareSelectedId) ?? flat[0] ?? null
|
||||||
|
|
||||||
|
const linkedChapter = selected?.chapterId
|
||||||
|
? chapters.find((c) => c.id === selected.chapterId) ?? null
|
||||||
|
: null
|
||||||
|
|
||||||
|
const actualWords = linkedChapter?.wordCount ?? 0
|
||||||
|
const expectedWords = selected?.expectedWordCount ?? null
|
||||||
|
const deviation = calcDeviation(expectedWords, actualWords)
|
||||||
|
const deviationHigh = deviation !== null && Math.abs(deviation) > 20
|
||||||
|
|
||||||
|
const handleSelect = (id: string): void => {
|
||||||
|
setCompareSelectedId(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleJumpEditor = (): void => {
|
||||||
|
if (!selected) return
|
||||||
|
setCompareMode(false)
|
||||||
|
if (selected.chapterId) {
|
||||||
|
void switchTarget({ kind: 'chapter', id: selected.chapterId })
|
||||||
|
} else {
|
||||||
|
void switchTarget({ kind: 'outline', id: selected.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="outline-compare-view" data-testid="outline-compare-view">
|
||||||
|
<div className="compare-toolbar">
|
||||||
|
<span>{t('outline.compareTitle')}</span>
|
||||||
|
<button type="button" className="btn" onClick={() => setCompareMode(false)}>
|
||||||
|
{t('outline.exitCompare')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="compare-body">
|
||||||
|
<div className="compare-left">
|
||||||
|
{flat.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`outline-item ${selected?.id === item.id ? 'active' : ''} ${!item.chapterId ? 'uncovered' : ''}`}
|
||||||
|
onClick={() => handleSelect(item.id)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{!item.chapterId && <span className="outline-warn">⚠</span>}
|
||||||
|
<span className="outline-title">{item.title}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{flat.length === 0 && <div className="sidebar-empty">{t('outline.emptyFilter')}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="compare-right">
|
||||||
|
{linkedChapter ? (
|
||||||
|
<div
|
||||||
|
className="compare-chapter-content"
|
||||||
|
dangerouslySetInnerHTML={{ __html: linkedChapter.content || `<p>${t('outline.noContent')}</p>` }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="placeholder-box">{t('outline.notWritten')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="compare-footer">
|
||||||
|
<span>{t('outline.expected')}: {expectedWords ?? '—'}</span>
|
||||||
|
<span>{t('outline.actual')}: {actualWords}</span>
|
||||||
|
<span className={deviationHigh ? 'compare-deviation-warn' : ''}>
|
||||||
|
{t('outline.deviation')}: {deviation !== null ? `${deviation}%` : '—'}
|
||||||
|
</span>
|
||||||
|
<button type="button" className="btn primary" onClick={handleJumpEditor}>
|
||||||
|
{t('outline.jumpEditor')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { OutlineItem } from '@shared/types'
|
||||||
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||||
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
|
import { useOutlineStore, filterOutlines } from '@renderer/stores/useOutlineStore'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
import { buildOutlineTree } from '@renderer/lib/outline-utils'
|
||||||
|
|
||||||
|
interface OutlineNodeProps {
|
||||||
|
item: OutlineItem
|
||||||
|
depth: number
|
||||||
|
activeId: string | null
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
onRename: (id: string, title: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function OutlineNode({ item, depth, activeId, onSelect, onRename }: OutlineNodeProps): React.JSX.Element {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [title, setTitle] = useState(item.title)
|
||||||
|
const uncovered = !item.chapterId
|
||||||
|
|
||||||
|
const commitTitle = (): void => {
|
||||||
|
setEditing(false)
|
||||||
|
const trimmed = title.trim()
|
||||||
|
if (trimmed && trimmed !== item.title) onRename(item.id, trimmed)
|
||||||
|
else setTitle(item.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`outline-item ${activeId === item.id ? 'active' : ''} ${uncovered ? 'uncovered' : ''}`}
|
||||||
|
style={{ paddingLeft: 12 + depth * 14 }}
|
||||||
|
onClick={() => onSelect(item.id)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && onSelect(item.id)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
data-testid={`outline-item-${item.id}`}
|
||||||
|
data-uncovered={uncovered ? 'true' : 'false'}
|
||||||
|
title={uncovered ? '未关联章节' : undefined}
|
||||||
|
>
|
||||||
|
{uncovered && (
|
||||||
|
<span className="outline-warn" data-testid="outline-warn">
|
||||||
|
⚠
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
className="form-control outline-title-input"
|
||||||
|
value={title}
|
||||||
|
autoFocus
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
onBlur={() => commitTitle()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') commitTitle()
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setTitle(item.title)
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="outline-title"
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setEditing(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.children?.map((child) => (
|
||||||
|
<OutlineNode
|
||||||
|
key={child.id}
|
||||||
|
item={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
activeId={activeId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onRename={onRename}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OutlineTree(): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const showToast = useAppStore((s) => s.showToast)
|
||||||
|
const bookId = useBookStore((s) => s.currentBookId)
|
||||||
|
const outlines = useBookStore((s) => s.outlines)
|
||||||
|
const addOutlineLocal = useBookStore((s) => s.addOutlineLocal)
|
||||||
|
const updateOutlineLocal = useBookStore((s) => s.updateOutlineLocal)
|
||||||
|
const target = useEditStore((s) => s.target)
|
||||||
|
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||||
|
const filter = useOutlineStore((s) => s.filter)
|
||||||
|
const setFilter = useOutlineStore((s) => s.setFilter)
|
||||||
|
const setCompareMode = useOutlineStore((s) => s.setCompareMode)
|
||||||
|
const setCompareSelectedId = useOutlineStore((s) => s.setCompareSelectedId)
|
||||||
|
|
||||||
|
const filtered = useMemo(() => filterOutlines(outlines, filter), [outlines, filter])
|
||||||
|
const tree = useMemo(() => buildOutlineTree(filtered), [filtered])
|
||||||
|
const activeId = target?.kind === 'outline' ? target.id : null
|
||||||
|
|
||||||
|
const handleSelect = (id: string): void => {
|
||||||
|
void switchTarget({ kind: 'outline', id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRename = async (id: string, title: string): Promise<void> => {
|
||||||
|
if (!bookId) return
|
||||||
|
const updated = await ipcCall(() => window.electronAPI.outline.update(bookId, id, { title }))
|
||||||
|
updateOutlineLocal(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async (): Promise<void> => {
|
||||||
|
if (!bookId) {
|
||||||
|
showToast(t('toast.noBookOpen'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const item = await ipcCall(() =>
|
||||||
|
window.electronAPI.outline.create(bookId, t('outline.newItem'))
|
||||||
|
)
|
||||||
|
addOutlineLocal(item)
|
||||||
|
await switchTarget({ kind: 'outline', id: item.id })
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e instanceof Error ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCompare = (): void => {
|
||||||
|
setCompareSelectedId(filtered[0]?.id ?? null)
|
||||||
|
setCompareMode(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="outline-toolbar">
|
||||||
|
<select
|
||||||
|
data-testid="outline-filter"
|
||||||
|
className="form-control"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value as typeof filter)}
|
||||||
|
>
|
||||||
|
<option value="all">{t('outline.filterAll')}</option>
|
||||||
|
<option value="uncovered">{t('outline.filterUncovered')}</option>
|
||||||
|
<option value="covered">{t('outline.filterCovered')}</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" className="btn" data-testid="outline-compare" onClick={handleCompare}>
|
||||||
|
⇔ {t('outline.compare')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-list" data-testid="outline-tree">
|
||||||
|
{tree.length === 0 && <div className="sidebar-empty">{t('outline.empty')}</div>}
|
||||||
|
{tree.map((item) => (
|
||||||
|
<OutlineNode
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
depth={0}
|
||||||
|
activeId={activeId}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onRename={(id, title) => void handleRename(id, title)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<button type="button" data-testid="outline-new" onClick={() => void handleCreate()}>
|
||||||
|
+ {t('outline.new')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
|
||||||
|
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||||
|
|
||||||
|
export function ReferencePanel(): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const open = useReferenceStore((s) => s.open)
|
||||||
|
const tab = useReferenceStore((s) => s.tab)
|
||||||
|
const query = useReferenceStore((s) => s.query)
|
||||||
|
const setTab = useReferenceStore((s) => s.setTab)
|
||||||
|
const setQuery = useReferenceStore((s) => s.setQuery)
|
||||||
|
const setOpen = useReferenceStore((s) => s.setOpen)
|
||||||
|
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||||
|
const outlines = useBookStore((s) => s.outlines)
|
||||||
|
const settings = useBookStore((s) => s.settings)
|
||||||
|
const inspirations = useBookStore((s) => s.inspirations)
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
const match = (title: string, body: string) =>
|
||||||
|
!q || title.toLowerCase().includes(q) || body.toLowerCase().includes(q)
|
||||||
|
|
||||||
|
if (tab === 'setting') {
|
||||||
|
return settings
|
||||||
|
.filter((s) => match(s.name, s.description))
|
||||||
|
.map((s) => ({ id: s.id, kind: 'setting' as const, title: s.name, preview: s.description }))
|
||||||
|
}
|
||||||
|
if (tab === 'outline') {
|
||||||
|
return outlines
|
||||||
|
.filter((o) => match(o.title, o.description))
|
||||||
|
.map((o) => ({ id: o.id, kind: 'outline' as const, title: o.title, preview: o.description }))
|
||||||
|
}
|
||||||
|
return inspirations
|
||||||
|
.filter((i) => match(i.title, i.content))
|
||||||
|
.map((i) => ({ id: i.id, kind: 'inspiration' as const, title: i.title, preview: i.content }))
|
||||||
|
}, [tab, query, settings, outlines, inspirations])
|
||||||
|
|
||||||
|
if (!open) return <div id="reference-panel" />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="reference-panel" className="open" data-testid="reference-panel">
|
||||||
|
<div className="ref-header">
|
||||||
|
<span>{t('reference.title')}</span>
|
||||||
|
<button type="button" className="btn" onClick={() => setOpen(false)}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
placeholder={t('reference.search')}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="ref-tabs">
|
||||||
|
{(['setting', 'outline', 'inspiration'] as const).map((id) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
className={`ref-tab ${tab === id ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab(id)}
|
||||||
|
>
|
||||||
|
{t(`reference.tab.${id}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="ref-list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="ref-item"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
const plain = item.preview.replace(/<[^>]+>/g, '').slice(0, 200)
|
||||||
|
e.dataTransfer.setData('text/plain', plain)
|
||||||
|
}}
|
||||||
|
onClick={() => void switchTarget({ kind: item.kind, id: item.id })}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
data-testid={`ref-item-${item.id}`}
|
||||||
|
>
|
||||||
|
<div className="ref-item-title">{item.title}</div>
|
||||||
|
<div className="ref-item-preview">
|
||||||
|
{item.preview.replace(/<[^>]+>/g, '').slice(0, 40)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { SearchResult } from '@shared/types'
|
||||||
|
import { useSearchStore } from '@renderer/stores/useSearchStore'
|
||||||
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
|
export function SearchModal(): React.JSX.Element | null {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const open = useSearchStore((s) => s.open)
|
||||||
|
const query = useSearchStore((s) => s.query)
|
||||||
|
const regex = useSearchStore((s) => s.regex)
|
||||||
|
const caseSensitive = useSearchStore((s) => s.caseSensitive)
|
||||||
|
const setOpen = useSearchStore((s) => s.setOpen)
|
||||||
|
const setQuery = useSearchStore((s) => s.setQuery)
|
||||||
|
const setRegex = useSearchStore((s) => s.setRegex)
|
||||||
|
const setCaseSensitive = useSearchStore((s) => s.setCaseSensitive)
|
||||||
|
const bookId = useBookStore((s) => s.currentBookId)
|
||||||
|
const setSelectedChapter = useBookStore((s) => s.setSelectedChapter)
|
||||||
|
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([])
|
||||||
|
const [history, setHistory] = useState<string[]>([])
|
||||||
|
const [selectedIdx, setSelectedIdx] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
void ipcCall(() => window.electronAPI.search.getHistory()).then(setHistory)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !bookId || !query.trim()) {
|
||||||
|
setResults([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
void ipcCall(() =>
|
||||||
|
window.electronAPI.search.query(bookId, query, { regex, caseSensitive })
|
||||||
|
).then(setResults)
|
||||||
|
}, 300)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [open, bookId, query, regex, caseSensitive])
|
||||||
|
|
||||||
|
const jumpTo = async (hit: SearchResult): Promise<void> => {
|
||||||
|
if (!bookId) return
|
||||||
|
await ipcCall(() => window.electronAPI.search.saveHistory(query))
|
||||||
|
setOpen(false)
|
||||||
|
if (hit.kind === 'chapter' || hit.kind === 'bookmark') {
|
||||||
|
const chapterId = hit.chapterId ?? hit.id
|
||||||
|
setSelectedChapter(chapterId)
|
||||||
|
await switchTarget({ kind: 'chapter', id: chapterId })
|
||||||
|
} else if (hit.kind === 'outline') {
|
||||||
|
await switchTarget({ kind: 'outline', id: hit.id })
|
||||||
|
} else if (hit.kind === 'setting') {
|
||||||
|
await switchTarget({ kind: 'setting', id: hit.id })
|
||||||
|
} else if (hit.kind === 'inspiration') {
|
||||||
|
await switchTarget({ kind: 'inspiration', id: hit.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" id="searchModal" data-testid="search-modal">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>{t('search.title')}</h3>
|
||||||
|
<button type="button" className="modal-close" onClick={() => setOpen(false)}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
data-testid="search-input"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={t('search.placeholder')}
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'ArrowDown') setSelectedIdx((i) => Math.min(i + 1, results.length - 1))
|
||||||
|
if (e.key === 'ArrowUp') setSelectedIdx((i) => Math.max(i - 1, 0))
|
||||||
|
if (e.key === 'Enter' && results[selectedIdx]) void jumpTo(results[selectedIdx])
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="search-options">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={regex} onChange={(e) => setRegex(e.target.checked)} />
|
||||||
|
{t('search.regex')}
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={caseSensitive}
|
||||||
|
onChange={(e) => setCaseSensitive(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('search.caseSensitive')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{history.length > 0 && (
|
||||||
|
<div className="search-history">
|
||||||
|
{history.slice(0, 5).map((h) => (
|
||||||
|
<button key={h} type="button" className="btn" onClick={() => setQuery(h)}>
|
||||||
|
{h}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="search-results">
|
||||||
|
{results.map((hit, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${hit.kind}-${hit.id}`}
|
||||||
|
className={`search-result ${idx === selectedIdx ? 'active' : ''}`}
|
||||||
|
onClick={() => void jumpTo(hit)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="sr-type">
|
||||||
|
{hit.kind} · {hit.title}
|
||||||
|
</div>
|
||||||
|
<div>{hit.snippet}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn" onClick={() => setOpen(false)}>
|
||||||
|
{t('dialog.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { SettingType } from '@shared/types'
|
||||||
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
|
const SETTING_TYPES: SettingType[] = [
|
||||||
|
'character',
|
||||||
|
'location',
|
||||||
|
'item',
|
||||||
|
'skill',
|
||||||
|
'faction',
|
||||||
|
'custom'
|
||||||
|
]
|
||||||
|
|
||||||
|
export function SettingList(): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const bookId = useBookStore((s) => s.currentBookId)
|
||||||
|
const settings = useBookStore((s) => s.settings)
|
||||||
|
const addSettingLocal = useBookStore((s) => s.addSettingLocal)
|
||||||
|
const target = useEditStore((s) => s.target)
|
||||||
|
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||||
|
const [showDialog, setShowDialog] = useState(false)
|
||||||
|
const [newType, setNewType] = useState<SettingType>('character')
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
const grouped = SETTING_TYPES.map((type) => ({
|
||||||
|
type,
|
||||||
|
items: settings.filter((s) => s.type === type)
|
||||||
|
})).filter((g) => g.items.length > 0 || !collapsed[g.type])
|
||||||
|
|
||||||
|
const activeId = target?.kind === 'setting' ? target.id : null
|
||||||
|
|
||||||
|
const handleCreate = async (): Promise<void> => {
|
||||||
|
if (!bookId || !newName.trim()) return
|
||||||
|
const item = await ipcCall(() =>
|
||||||
|
window.electronAPI.setting.create(bookId, newType, newName.trim())
|
||||||
|
)
|
||||||
|
addSettingLocal(item)
|
||||||
|
setShowDialog(false)
|
||||||
|
setNewName('')
|
||||||
|
await switchTarget({ kind: 'setting', id: item.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="sidebar-list" data-testid="setting-list">
|
||||||
|
{settings.length === 0 && (
|
||||||
|
<div className="sidebar-empty">{t('setting.empty')}</div>
|
||||||
|
)}
|
||||||
|
{SETTING_TYPES.map((type) => {
|
||||||
|
const items = settings.filter((s) => s.type === type)
|
||||||
|
if (items.length === 0) return null
|
||||||
|
const isCollapsed = collapsed[type]
|
||||||
|
return (
|
||||||
|
<div key={type} className="setting-group">
|
||||||
|
<div
|
||||||
|
className="setting-group-header"
|
||||||
|
onClick={() => setCollapsed((c) => ({ ...c, [type]: !c[type] }))}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{isCollapsed ? '▶' : '▼'} {t(`setting.type.${type}`)}
|
||||||
|
<span className="setting-group-count">{items.length}</span>
|
||||||
|
</div>
|
||||||
|
{!isCollapsed &&
|
||||||
|
items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`setting-item ${activeId === item.id ? 'active' : ''}`}
|
||||||
|
onClick={() => void switchTarget({ kind: 'setting', id: item.id })}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && void switchTarget({ kind: 'setting', id: item.id })}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
data-testid={`setting-item-${item.id}`}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<button type="button" data-testid="setting-new" onClick={() => setShowDialog(true)}>
|
||||||
|
+ {t('setting.new')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showDialog && (
|
||||||
|
<div className="dialog-overlay" onClick={() => setShowDialog(false)}>
|
||||||
|
<div className="dialog-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{t('setting.new')}</h3>
|
||||||
|
<label className="form-label">
|
||||||
|
{t('setting.typeLabel')}
|
||||||
|
<select
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
value={newType}
|
||||||
|
onChange={(e) => setNewType(e.target.value as SettingType)}
|
||||||
|
>
|
||||||
|
{SETTING_TYPES.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{t(`setting.type.${type}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="form-label">
|
||||||
|
{t('setting.nameLabel')}
|
||||||
|
<input
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
data-testid="setting-name-input"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button type="button" className="btn" onClick={() => setShowDialog(false)}>
|
||||||
|
{t('dialog.cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn primary" onClick={() => void handleCreate()}>
|
||||||
|
{t('dialog.create')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -48,25 +48,25 @@ export function SettingsPage(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-content">
|
<div className="settings-content">
|
||||||
<h2 style={{ marginBottom: 20 }}>{t('settings.title')}</h2>
|
<h2 className="settings-heading">{t('settings.title')}</h2>
|
||||||
{section === 'general' && (
|
{section === 'general' && (
|
||||||
<>
|
<>
|
||||||
<div className="setting-item">
|
<div className="setting-item">
|
||||||
<span>{t('settings.penName')}</span>
|
<span>{t('settings.penName')}</span>
|
||||||
<input
|
<input
|
||||||
data-testid="settings-pen-name"
|
data-testid="settings-pen-name"
|
||||||
|
className="form-control form-control--narrow"
|
||||||
value={settings.penName}
|
value={settings.penName}
|
||||||
onChange={(e) => void settings.update({ penName: e.target.value })}
|
onChange={(e) => void settings.update({ penName: e.target.value })}
|
||||||
style={{ width: 160, padding: '6px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--bg-surface)', color: 'inherit' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="setting-item">
|
<div className="setting-item">
|
||||||
<span>{t('settings.theme')}</span>
|
<span>{t('settings.theme')}</span>
|
||||||
<select
|
<select
|
||||||
data-testid="settings-theme"
|
data-testid="settings-theme"
|
||||||
|
className="form-control"
|
||||||
value={settings.theme}
|
value={settings.theme}
|
||||||
onChange={(e) => void settings.update({ theme: e.target.value as ThemeId })}
|
onChange={(e) => void settings.update({ theme: e.target.value as ThemeId })}
|
||||||
style={{ padding: '6px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--bg-surface)', color: 'inherit' }}
|
|
||||||
>
|
>
|
||||||
{THEMES.map((th) => (
|
{THEMES.map((th) => (
|
||||||
<option key={th.id} value={th.id}>
|
<option key={th.id} value={th.id}>
|
||||||
@@ -79,9 +79,9 @@ export function SettingsPage(): React.JSX.Element {
|
|||||||
<span>{t('settings.language')}</span>
|
<span>{t('settings.language')}</span>
|
||||||
<select
|
<select
|
||||||
data-testid="settings-language"
|
data-testid="settings-language"
|
||||||
|
className="form-control"
|
||||||
value={settings.language}
|
value={settings.language}
|
||||||
onChange={(e) => void settings.update({ language: e.target.value as Language })}
|
onChange={(e) => void settings.update({ language: e.target.value as Language })}
|
||||||
style={{ padding: '6px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--bg-surface)', color: 'inherit' }}
|
|
||||||
>
|
>
|
||||||
<option value="zh-CN">简体中文</option>
|
<option value="zh-CN">简体中文</option>
|
||||||
<option value="en">English</option>
|
<option value="en">English</option>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import DiffMatchPatch from 'diff-match-patch'
|
||||||
|
import type { Snapshot } from '@shared/types'
|
||||||
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||||
|
|
||||||
|
interface VersionModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
chapterId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(text: string): string {
|
||||||
|
return text.replace(/<[^>]+>/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDiff(oldText: string, newText: string): React.JSX.Element {
|
||||||
|
const dmp = new DiffMatchPatch()
|
||||||
|
const diffs = dmp.diff_main(oldText, newText)
|
||||||
|
dmp.diff_cleanupSemantic(diffs)
|
||||||
|
return (
|
||||||
|
<div className="diff-view">
|
||||||
|
{diffs.map(([op, text], i) => {
|
||||||
|
if (op === 1) return <ins key={i}>{text}</ins>
|
||||||
|
if (op === -1) return <del key={i}>{text}</del>
|
||||||
|
return <span key={i}>{text}</span>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VersionModal({ open, onClose, chapterId }: VersionModalProps): React.JSX.Element | null {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const bookId = useBookStore((s) => s.currentBookId)
|
||||||
|
const chapters = useBookStore((s) => s.chapters)
|
||||||
|
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
|
||||||
|
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const currentChapter = chapterId ? chapters.find((c) => c.id === chapterId) : null
|
||||||
|
const selected = snapshots.find((s) => s.id === selectedId) ?? null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !bookId || !chapterId) return
|
||||||
|
void ipcCall(() => window.electronAPI.snapshot.list(bookId, chapterId)).then((list) => {
|
||||||
|
setSnapshots(list)
|
||||||
|
setSelectedId(list[0]?.id ?? null)
|
||||||
|
})
|
||||||
|
}, [open, bookId, chapterId])
|
||||||
|
|
||||||
|
const handleCreate = async (): Promise<void> => {
|
||||||
|
if (!bookId || !chapterId || !currentChapter) return
|
||||||
|
await flushEditorSave()
|
||||||
|
const snap = await ipcCall(() =>
|
||||||
|
window.electronAPI.snapshot.create(bookId, chapterId, 'manual', t('version.manual'), currentChapter.content)
|
||||||
|
)
|
||||||
|
setSnapshots((s) => [snap, ...s])
|
||||||
|
setSelectedId(snap.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = async (): Promise<void> => {
|
||||||
|
if (!bookId || !chapterId || !selectedId) return
|
||||||
|
if (!confirm(t('version.restoreConfirm'))) return
|
||||||
|
const updated = await ipcCall(() =>
|
||||||
|
window.electronAPI.snapshot.restore(bookId, chapterId, selectedId)
|
||||||
|
)
|
||||||
|
updateChapterLocal(updated)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" id="versionModal" data-testid="version-modal">
|
||||||
|
<div className="modal-content modal-content--wide">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>{t('version.title')}</h3>
|
||||||
|
<button type="button" className="modal-close" onClick={onClose}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="version-layout">
|
||||||
|
<div className="version-list">
|
||||||
|
{snapshots.map((snap) => (
|
||||||
|
<button
|
||||||
|
key={snap.id}
|
||||||
|
type="button"
|
||||||
|
className={`version-item ${selectedId === snap.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setSelectedId(snap.id)}
|
||||||
|
>
|
||||||
|
<div>{snap.name || snap.type}</div>
|
||||||
|
<div className="version-time">{new Date(snap.createdAt).toLocaleString()}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{snapshots.length === 0 && <div className="sidebar-empty">{t('version.empty')}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="version-diff">
|
||||||
|
{selected && currentChapter
|
||||||
|
? renderDiff(stripHtml(selected.content), stripHtml(currentChapter.content))
|
||||||
|
: t('version.selectHint')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn" onClick={onClose}>
|
||||||
|
{t('dialog.cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn" onClick={() => void handleCreate()}>
|
||||||
|
{t('version.create')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn primary" onClick={() => void handleRestore()}>
|
||||||
|
{t('version.restore')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||||
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
import { highlightTokenInEditor } from '@renderer/lib/editor-commands'
|
||||||
|
|
||||||
|
export function WordFreqPanel(): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const showToast = useAppStore((s) => s.showToast)
|
||||||
|
const bookId = useBookStore((s) => s.currentBookId)
|
||||||
|
const chapters = useBookStore((s) => s.chapters)
|
||||||
|
const target = useEditStore((s) => s.target)
|
||||||
|
const [words, setWords] = useState<{ token: string; count: number }[]>([])
|
||||||
|
|
||||||
|
const chapterWords =
|
||||||
|
target?.kind === 'chapter'
|
||||||
|
? (chapters.find((c) => c.id === target.id)?.wordCount ?? 0)
|
||||||
|
: chapters.reduce((s, c) => s + c.wordCount, 0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bookId) return
|
||||||
|
const scope =
|
||||||
|
target?.kind === 'chapter'
|
||||||
|
? { kind: 'chapter' as const, chapterId: target.id }
|
||||||
|
: { kind: 'book' as const }
|
||||||
|
void ipcCall(() => window.electronAPI.wordfreq.analyze(bookId, scope)).then((r) => setWords(r.words))
|
||||||
|
}, [bookId, target?.kind, target?.id, chapterWords])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wordfreq-panel" data-testid="wordfreq-panel">
|
||||||
|
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8 }}>{t('wordfreq.hint')}</p>
|
||||||
|
<div className="wordfreq-list">
|
||||||
|
{words.map((w) => {
|
||||||
|
const perThousand = chapterWords > 0 ? (w.count / chapterWords) * 1000 : 0
|
||||||
|
const overuse = perThousand > 5
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={w.token}
|
||||||
|
className={`wordfreq-item ${overuse ? 'overuse' : ''}`}
|
||||||
|
data-testid={`wordfreq-${w.token}`}
|
||||||
|
onClick={() => highlightTokenInEditor(w.token)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<span>{w.token}</span>
|
||||||
|
<span>{w.count}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
onClick={() => showToast(t('feature.comingSoon'))}
|
||||||
|
>
|
||||||
|
{t('wordfreq.aiNaming')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Mark, mergeAttributes } from '@tiptap/core'
|
||||||
|
|
||||||
|
export const LandmarkMark = Mark.create({
|
||||||
|
name: 'landmark',
|
||||||
|
inclusive: true,
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
label: { default: '' },
|
||||||
|
landmarkType: { default: 'todo' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'span[data-landmark]' }]
|
||||||
|
},
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
const label = HTMLAttributes.label ?? ''
|
||||||
|
return [
|
||||||
|
'span',
|
||||||
|
mergeAttributes(HTMLAttributes, {
|
||||||
|
'data-landmark': 'true',
|
||||||
|
class: 'landmark-mark',
|
||||||
|
'data-testid': 'landmark-mark'
|
||||||
|
}),
|
||||||
|
`[TODO: ${label}]`
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Mark, mergeAttributes } from '@tiptap/core'
|
||||||
|
|
||||||
|
export const SettingMention = Mark.create({
|
||||||
|
name: 'settingMention',
|
||||||
|
inclusive: false,
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
id: { default: null },
|
||||||
|
label: { default: null }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'span[data-setting-mention]' }]
|
||||||
|
},
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
const label = HTMLAttributes.label ?? ''
|
||||||
|
return [
|
||||||
|
'span',
|
||||||
|
mergeAttributes(HTMLAttributes, {
|
||||||
|
'data-setting-mention': 'true',
|
||||||
|
class: 'setting-mention',
|
||||||
|
'data-testid': 'setting-mention'
|
||||||
|
}),
|
||||||
|
`@${label}`
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export interface LandmarkInsert {
|
||||||
|
label: string
|
||||||
|
landmarkType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let insertLandmarkFn: ((payload: LandmarkInsert) => void) | null = null
|
||||||
|
let highlightTokenFn: ((token: string) => void) | null = null
|
||||||
|
|
||||||
|
export function registerInsertLandmark(fn: (payload: LandmarkInsert) => void): void {
|
||||||
|
insertLandmarkFn = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerHighlightToken(fn: (token: string) => void): void {
|
||||||
|
highlightTokenFn = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertLandmarkInEditor(payload: LandmarkInsert): void {
|
||||||
|
insertLandmarkFn?.(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightTokenInEditor(token: string): void {
|
||||||
|
highlightTokenFn?.(token)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { OutlineItem } from '@shared/types'
|
||||||
|
|
||||||
|
export function buildOutlineTree(items: OutlineItem[]): OutlineItem[] {
|
||||||
|
const byParent = new Map<string | null, OutlineItem[]>()
|
||||||
|
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 function flattenOutlineTree(items: OutlineItem[]): OutlineItem[] {
|
||||||
|
const result: OutlineItem[] = []
|
||||||
|
const walk = (nodes: OutlineItem[]): void => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
result.push(node)
|
||||||
|
if (node.children?.length) walk(node.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(items)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcDeviation(expected: number | null, actual: number): number | null {
|
||||||
|
if (!expected || expected <= 0) return null
|
||||||
|
return Math.round(((actual - expected) / expected) * 100)
|
||||||
|
}
|
||||||
@@ -6,10 +6,14 @@ interface AppState {
|
|||||||
view: AppView
|
view: AppView
|
||||||
sidebarPanel: 'chapters' | 'outline' | 'setting' | 'inspiration'
|
sidebarPanel: 'chapters' | 'outline' | 'setting' | 'inspiration'
|
||||||
rightPanel: 'ai' | 'knowledge' | 'wordfreq' | 'book-settings'
|
rightPanel: 'ai' | 'knowledge' | 'wordfreq' | 'book-settings'
|
||||||
|
versionModalOpen: boolean
|
||||||
|
landmarksModalOpen: boolean
|
||||||
toast: string | null
|
toast: string | null
|
||||||
setView: (view: AppView) => void
|
setView: (view: AppView) => void
|
||||||
setSidebarPanel: (panel: AppState['sidebarPanel']) => void
|
setSidebarPanel: (panel: AppState['sidebarPanel']) => void
|
||||||
setRightPanel: (panel: AppState['rightPanel']) => void
|
setRightPanel: (panel: AppState['rightPanel']) => void
|
||||||
|
setVersionModalOpen: (open: boolean) => void
|
||||||
|
setLandmarksModalOpen: (open: boolean) => void
|
||||||
showToast: (message: string) => void
|
showToast: (message: string) => void
|
||||||
clearToast: () => void
|
clearToast: () => void
|
||||||
}
|
}
|
||||||
@@ -18,10 +22,14 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
view: 'home',
|
view: 'home',
|
||||||
sidebarPanel: 'chapters',
|
sidebarPanel: 'chapters',
|
||||||
rightPanel: 'ai',
|
rightPanel: 'ai',
|
||||||
|
versionModalOpen: false,
|
||||||
|
landmarksModalOpen: false,
|
||||||
toast: null,
|
toast: null,
|
||||||
setView: (view) => set({ view }),
|
setView: (view) => set({ view }),
|
||||||
setSidebarPanel: (sidebarPanel) => set({ sidebarPanel }),
|
setSidebarPanel: (sidebarPanel) => set({ sidebarPanel }),
|
||||||
setRightPanel: (rightPanel) => set({ rightPanel }),
|
setRightPanel: (rightPanel) => set({ rightPanel }),
|
||||||
|
setVersionModalOpen: (versionModalOpen) => set({ versionModalOpen }),
|
||||||
|
setLandmarksModalOpen: (landmarksModalOpen) => set({ landmarksModalOpen }),
|
||||||
showToast: (toast) => {
|
showToast: (toast) => {
|
||||||
set({ toast })
|
set({ toast })
|
||||||
setTimeout(() => set({ toast: null }), 2500)
|
setTimeout(() => set({ toast: null }), 2500)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { BookMeta, Volume, Chapter } from '@shared/types'
|
import type { BookMeta, Volume, Chapter, OutlineItem, SettingEntry, InspirationEntry } from '@shared/types'
|
||||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
interface BookState {
|
interface BookState {
|
||||||
@@ -7,6 +7,9 @@ interface BookState {
|
|||||||
currentBookId: string | null
|
currentBookId: string | null
|
||||||
volumes: Volume[]
|
volumes: Volume[]
|
||||||
chapters: Chapter[]
|
chapters: Chapter[]
|
||||||
|
outlines: OutlineItem[]
|
||||||
|
settings: SettingEntry[]
|
||||||
|
inspirations: InspirationEntry[]
|
||||||
selectedChapterId: string | null
|
selectedChapterId: string | null
|
||||||
activeVolumeId: string | null
|
activeVolumeId: string | null
|
||||||
loadBooks: () => Promise<void>
|
loadBooks: () => Promise<void>
|
||||||
@@ -14,7 +17,17 @@ interface BookState {
|
|||||||
setSelectedChapter: (chapterId: string) => void
|
setSelectedChapter: (chapterId: string) => void
|
||||||
setActiveVolume: (volumeId: string) => void
|
setActiveVolume: (volumeId: string) => void
|
||||||
refreshChapters: () => Promise<void>
|
refreshChapters: () => Promise<void>
|
||||||
|
refreshBookData: () => Promise<void>
|
||||||
updateChapterLocal: (chapter: Chapter) => void
|
updateChapterLocal: (chapter: Chapter) => void
|
||||||
|
updateOutlineLocal: (item: OutlineItem) => void
|
||||||
|
addOutlineLocal: (item: OutlineItem) => void
|
||||||
|
removeOutlineLocal: (id: string) => void
|
||||||
|
updateSettingLocal: (item: SettingEntry) => void
|
||||||
|
addSettingLocal: (item: SettingEntry) => void
|
||||||
|
removeSettingLocal: (id: string) => void
|
||||||
|
updateInspirationLocal: (item: InspirationEntry) => void
|
||||||
|
addInspirationLocal: (item: InspirationEntry) => void
|
||||||
|
removeInspirationLocal: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useBookStore = create<BookState>((set, get) => ({
|
export const useBookStore = create<BookState>((set, get) => ({
|
||||||
@@ -22,6 +35,9 @@ export const useBookStore = create<BookState>((set, get) => ({
|
|||||||
currentBookId: null,
|
currentBookId: null,
|
||||||
volumes: [],
|
volumes: [],
|
||||||
chapters: [],
|
chapters: [],
|
||||||
|
outlines: [],
|
||||||
|
settings: [],
|
||||||
|
inspirations: [],
|
||||||
selectedChapterId: null,
|
selectedChapterId: null,
|
||||||
activeVolumeId: null,
|
activeVolumeId: null,
|
||||||
loadBooks: async () => {
|
loadBooks: async () => {
|
||||||
@@ -40,6 +56,9 @@ export const useBookStore = create<BookState>((set, get) => ({
|
|||||||
currentBookId: bookId,
|
currentBookId: bookId,
|
||||||
volumes: result.volumes,
|
volumes: result.volumes,
|
||||||
chapters: result.chapters,
|
chapters: result.chapters,
|
||||||
|
outlines: result.outlines,
|
||||||
|
settings: result.settings,
|
||||||
|
inspirations: result.inspirations,
|
||||||
activeVolumeId: result.chapters.find((c) => c.id === lastChapter)?.volumeId ?? firstVolume,
|
activeVolumeId: result.chapters.find((c) => c.id === lastChapter)?.volumeId ?? firstVolume,
|
||||||
selectedChapterId: lastChapter
|
selectedChapterId: lastChapter
|
||||||
})
|
})
|
||||||
@@ -53,14 +72,56 @@ export const useBookStore = create<BookState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
setActiveVolume: (volumeId) => set({ activeVolumeId: volumeId }),
|
setActiveVolume: (volumeId) => set({ activeVolumeId: volumeId }),
|
||||||
refreshChapters: async () => {
|
refreshChapters: async () => {
|
||||||
|
await get().refreshBookData()
|
||||||
|
},
|
||||||
|
refreshBookData: async () => {
|
||||||
const bookId = get().currentBookId
|
const bookId = get().currentBookId
|
||||||
if (!bookId) return
|
if (!bookId) return
|
||||||
const result = await ipcCall(() => window.electronAPI.book.open(bookId))
|
const result = await ipcCall(() => window.electronAPI.book.open(bookId))
|
||||||
set({ volumes: result.volumes, chapters: result.chapters })
|
set({
|
||||||
|
volumes: result.volumes,
|
||||||
|
chapters: result.chapters,
|
||||||
|
outlines: result.outlines,
|
||||||
|
settings: result.settings,
|
||||||
|
inspirations: result.inspirations
|
||||||
|
})
|
||||||
},
|
},
|
||||||
updateChapterLocal: (chapter) => {
|
updateChapterLocal: (chapter) => {
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
chapters: s.chapters.map((c) => (c.id === chapter.id ? chapter : c))
|
chapters: s.chapters.map((c) => (c.id === chapter.id ? chapter : c))
|
||||||
}))
|
}))
|
||||||
|
},
|
||||||
|
updateOutlineLocal: (item) => {
|
||||||
|
set((s) => ({
|
||||||
|
outlines: s.outlines.map((o) => (o.id === item.id ? item : o))
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
addOutlineLocal: (item) => {
|
||||||
|
set((s) => ({ outlines: [...s.outlines, item] }))
|
||||||
|
},
|
||||||
|
removeOutlineLocal: (id) => {
|
||||||
|
set((s) => ({ outlines: s.outlines.filter((o) => o.id !== id) }))
|
||||||
|
},
|
||||||
|
updateSettingLocal: (item) => {
|
||||||
|
set((s) => ({
|
||||||
|
settings: s.settings.map((x) => (x.id === item.id ? item : x))
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
addSettingLocal: (item) => {
|
||||||
|
set((s) => ({ settings: [...s.settings, item] }))
|
||||||
|
},
|
||||||
|
removeSettingLocal: (id) => {
|
||||||
|
set((s) => ({ settings: s.settings.filter((x) => x.id !== id) }))
|
||||||
|
},
|
||||||
|
updateInspirationLocal: (item) => {
|
||||||
|
set((s) => ({
|
||||||
|
inspirations: s.inspirations.map((x) => (x.id === item.id ? item : x))
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
addInspirationLocal: (item) => {
|
||||||
|
set((s) => ({ inspirations: [...s.inspirations, item] }))
|
||||||
|
},
|
||||||
|
removeInspirationLocal: (id) => {
|
||||||
|
set((s) => ({ inspirations: s.inspirations.filter((x) => x.id !== id) }))
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { EditTarget } from '@shared/types'
|
||||||
|
|
||||||
|
interface EditState {
|
||||||
|
target: EditTarget | null
|
||||||
|
switchTarget: (next: EditTarget) => Promise<void>
|
||||||
|
setTarget: (next: EditTarget | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let flushFn: (() => Promise<void>) | null = null
|
||||||
|
|
||||||
|
export function registerEditorFlush(fn: () => Promise<void>): void {
|
||||||
|
flushFn = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flushEditSession(): Promise<void> {
|
||||||
|
if (flushFn) await flushFn()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEditStore = create<EditState>((set) => ({
|
||||||
|
target: null,
|
||||||
|
switchTarget: async (next) => {
|
||||||
|
await flushEditSession()
|
||||||
|
set({ target: next })
|
||||||
|
},
|
||||||
|
setTarget: (next) => set({ target: next })
|
||||||
|
}))
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { OutlineItem } from '@shared/types'
|
||||||
|
|
||||||
|
export type OutlineFilter = 'all' | 'uncovered' | 'covered'
|
||||||
|
|
||||||
|
interface OutlineState {
|
||||||
|
compareMode: boolean
|
||||||
|
filter: OutlineFilter
|
||||||
|
compareSelectedId: string | null
|
||||||
|
setCompareMode: (open: boolean) => void
|
||||||
|
setFilter: (filter: OutlineFilter) => void
|
||||||
|
setCompareSelectedId: (id: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterOutlines(items: OutlineItem[], filter: OutlineFilter): OutlineItem[] {
|
||||||
|
if (filter === 'all') return items
|
||||||
|
if (filter === 'uncovered') return items.filter((o) => !o.chapterId)
|
||||||
|
return items.filter((o) => Boolean(o.chapterId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOutlineStore = create<OutlineState>((set) => ({
|
||||||
|
compareMode: false,
|
||||||
|
filter: 'all',
|
||||||
|
compareSelectedId: null,
|
||||||
|
setCompareMode: (compareMode) => set({ compareMode }),
|
||||||
|
setFilter: (filter) => set({ filter }),
|
||||||
|
setCompareSelectedId: (compareSelectedId) => set({ compareSelectedId })
|
||||||
|
}))
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
type ReferenceTab = 'setting' | 'outline' | 'inspiration'
|
||||||
|
|
||||||
|
interface ReferenceState {
|
||||||
|
open: boolean
|
||||||
|
tab: ReferenceTab
|
||||||
|
query: string
|
||||||
|
pinnedIds: string[]
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
setTab: (tab: ReferenceTab) => void
|
||||||
|
setQuery: (query: string) => void
|
||||||
|
togglePin: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useReferenceStore = create<ReferenceState>((set, get) => ({
|
||||||
|
open: false,
|
||||||
|
tab: 'setting',
|
||||||
|
query: '',
|
||||||
|
pinnedIds: [],
|
||||||
|
setOpen: (open) => set({ open }),
|
||||||
|
setTab: (tab) => set({ tab }),
|
||||||
|
setQuery: (query) => set({ query }),
|
||||||
|
togglePin: (id) => {
|
||||||
|
const pinned = get().pinnedIds
|
||||||
|
set({
|
||||||
|
pinnedIds: pinned.includes(id) ? pinned.filter((x) => x !== id) : [...pinned, id]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}))
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
interface SearchState {
|
||||||
|
open: boolean
|
||||||
|
query: string
|
||||||
|
regex: boolean
|
||||||
|
caseSensitive: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
setQuery: (query: string) => void
|
||||||
|
setRegex: (regex: boolean) => void
|
||||||
|
setCaseSensitive: (caseSensitive: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSearchStore = create<SearchState>((set) => ({
|
||||||
|
open: false,
|
||||||
|
query: '',
|
||||||
|
regex: false,
|
||||||
|
caseSensitive: false,
|
||||||
|
setOpen: (open) => set({ open }),
|
||||||
|
setQuery: (query) => set({ query }),
|
||||||
|
setRegex: (regex) => set({ regex }),
|
||||||
|
setCaseSensitive: (caseSensitive) => set({ caseSensitive })
|
||||||
|
}))
|
||||||
@@ -33,6 +33,25 @@ input,
|
|||||||
select,
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
color: var(--input-text);
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--input-text);
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control--narrow {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control--wide {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
@@ -52,7 +71,7 @@ textarea {
|
|||||||
.btn.primary {
|
.btn.primary {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: #fff;
|
color: var(--text-on-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.primary:hover {
|
.btn.primary:hover {
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
|
|
||||||
.window-close:hover {
|
.window-close:hover {
|
||||||
background: var(--red) !important;
|
background: var(--red) !important;
|
||||||
color: #fff !important;
|
color: var(--text-on-accent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#main-area {
|
#main-area {
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
.btn-large.primary {
|
.btn-large.primary {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: #fff;
|
color: var(--text-on-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.book-grid {
|
.book-grid {
|
||||||
@@ -175,6 +175,7 @@
|
|||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
@@ -212,7 +213,121 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-panel.active {
|
.sidebar-panel.active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-empty {
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-item.uncovered {
|
||||||
|
border-left-color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-item.active,
|
||||||
|
.setting-item.active,
|
||||||
|
.inspiration-item.active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-warn {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--orange);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-title {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-title-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item {
|
||||||
|
padding: 7px 12px 7px 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspiration-item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspiration-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspiration-preview {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
display: block;
|
display: block;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vol-header {
|
.vol-header {
|
||||||
@@ -309,7 +424,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-content-wrap {
|
.editor-content-wrap {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--editor-bg);
|
background: var(--editor-bg);
|
||||||
}
|
}
|
||||||
@@ -424,6 +541,10 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-heading {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.setting-item {
|
.setting-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -513,3 +634,354 @@
|
|||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.outline-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-toolbar select {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-toolbar .btn {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#outline-compare-view {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-left {
|
||||||
|
width: 35%;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-right {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-chapter-content {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-deviation-warn {
|
||||||
|
color: var(--red);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#reference-panel {
|
||||||
|
width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#reference-panel.open {
|
||||||
|
width: 240px;
|
||||||
|
padding: 10px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-tab.active {
|
||||||
|
color: var(--accent-light);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
cursor: grab;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item-preview {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
width: min(560px, 92vw);
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content--wide {
|
||||||
|
width: min(720px, 95vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-history {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result.active,
|
||||||
|
.search-result:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-type {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent-light);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-list {
|
||||||
|
width: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item.active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-time {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-diff {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-view ins {
|
||||||
|
background: color-mix(in srgb, var(--green) 25%, transparent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-view del {
|
||||||
|
background: color-mix(in srgb, var(--red) 20%, transparent);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordfreq-panel {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordfreq-list {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordfreq-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordfreq-item.overuse {
|
||||||
|
background: color-mix(in srgb, var(--orange) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landmark-mark {
|
||||||
|
background: color-mix(in srgb, var(--orange) 20%, transparent);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-mention {
|
||||||
|
color: var(--accent-light);
|
||||||
|
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
bottom: 8px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 10;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
--text-secondary: #b8aca0;
|
--text-secondary: #b8aca0;
|
||||||
--text-muted: #786858;
|
--text-muted: #786858;
|
||||||
--text-dim: #584838;
|
--text-dim: #584838;
|
||||||
|
--text-on-accent: #ffffff;
|
||||||
|
--input-text: var(--text-primary);
|
||||||
|
--input-bg: var(--bg-surface);
|
||||||
|
--input-border: var(--border);
|
||||||
--accent: #c0504a;
|
--accent: #c0504a;
|
||||||
--accent-glow: rgba(192, 80, 74, 0.25);
|
--accent-glow: rgba(192, 80, 74, 0.25);
|
||||||
--accent-light: #d4706a;
|
--accent-light: #d4706a;
|
||||||
@@ -33,13 +37,18 @@
|
|||||||
--bg-tertiary: #242820;
|
--bg-tertiary: #242820;
|
||||||
--bg-surface: #282c24;
|
--bg-surface: #282c24;
|
||||||
--bg-hover: #2e322a;
|
--bg-hover: #2e322a;
|
||||||
|
--bg-active: #343830;
|
||||||
--bg-card: #20241c;
|
--bg-card: #20241c;
|
||||||
--text-primary: #e0e4d8;
|
--text-primary: #e0e4d8;
|
||||||
--text-secondary: #b0b8a8;
|
--text-secondary: #b0b8a8;
|
||||||
--text-muted: #687858;
|
--text-muted: #687858;
|
||||||
|
--text-dim: #506040;
|
||||||
--accent: #7a9a60;
|
--accent: #7a9a60;
|
||||||
|
--accent-glow: rgba(122, 154, 96, 0.25);
|
||||||
--accent-light: #98b878;
|
--accent-light: #98b878;
|
||||||
|
--accent-dark: #5a7848;
|
||||||
--border: #303828;
|
--border: #303828;
|
||||||
|
--border-light: #404830;
|
||||||
--editor-bg: #1a1e18;
|
--editor-bg: #1a1e18;
|
||||||
--editor-text: #e0e4d8;
|
--editor-text: #e0e4d8;
|
||||||
}
|
}
|
||||||
@@ -48,13 +57,20 @@
|
|||||||
--bg-primary: #1a1e24;
|
--bg-primary: #1a1e24;
|
||||||
--bg-secondary: #1e2228;
|
--bg-secondary: #1e2228;
|
||||||
--bg-tertiary: #242830;
|
--bg-tertiary: #242830;
|
||||||
|
--bg-surface: #282c34;
|
||||||
--bg-hover: #2e3238;
|
--bg-hover: #2e3238;
|
||||||
|
--bg-active: #343840;
|
||||||
--bg-card: #202428;
|
--bg-card: #202428;
|
||||||
--text-primary: #d8dce4;
|
--text-primary: #d8dce4;
|
||||||
--text-secondary: #a8b0b8;
|
--text-secondary: #a8b0b8;
|
||||||
|
--text-muted: #687078;
|
||||||
|
--text-dim: #505860;
|
||||||
--accent: #8098b8;
|
--accent: #8098b8;
|
||||||
|
--accent-glow: rgba(128, 152, 184, 0.25);
|
||||||
--accent-light: #a0b4d0;
|
--accent-light: #a0b4d0;
|
||||||
|
--accent-dark: #607890;
|
||||||
--border: #2a3038;
|
--border: #2a3038;
|
||||||
|
--border-light: #3a4048;
|
||||||
--editor-bg: #1a1e24;
|
--editor-bg: #1a1e24;
|
||||||
--editor-text: #d8dce4;
|
--editor-text: #d8dce4;
|
||||||
}
|
}
|
||||||
@@ -63,14 +79,27 @@
|
|||||||
--bg-primary: #f4efe4;
|
--bg-primary: #f4efe4;
|
||||||
--bg-secondary: #faf6ee;
|
--bg-secondary: #faf6ee;
|
||||||
--bg-tertiary: #efe8d8;
|
--bg-tertiary: #efe8d8;
|
||||||
|
--bg-surface: #f8f4ea;
|
||||||
--bg-hover: #e8e0d0;
|
--bg-hover: #e8e0d0;
|
||||||
|
--bg-active: #e0d8c8;
|
||||||
--bg-card: #fcf6ec;
|
--bg-card: #fcf6ec;
|
||||||
--text-primary: #3a3028;
|
--text-primary: #3a3028;
|
||||||
--text-secondary: #6a5a48;
|
--text-secondary: #6a5a48;
|
||||||
--text-muted: #988878;
|
--text-muted: #988878;
|
||||||
|
--text-dim: #b8a898;
|
||||||
|
--text-on-accent: #ffffff;
|
||||||
|
--input-text: #3a3028;
|
||||||
|
--input-bg: #ffffff;
|
||||||
|
--input-border: #d0c8b8;
|
||||||
--accent: #8b4513;
|
--accent: #8b4513;
|
||||||
|
--accent-glow: rgba(139, 69, 19, 0.15);
|
||||||
--accent-light: #a86030;
|
--accent-light: #a86030;
|
||||||
|
--accent-dark: #6b3509;
|
||||||
|
--green: #507850;
|
||||||
|
--orange: #a07040;
|
||||||
|
--red: #a04030;
|
||||||
--border: #d8d0c0;
|
--border: #d8d0c0;
|
||||||
|
--border-light: #c8c0b0;
|
||||||
--editor-bg: #fdf9f2;
|
--editor-bg: #fdf9f2;
|
||||||
--editor-text: #3a3028;
|
--editor-text: #3a3028;
|
||||||
}
|
}
|
||||||
@@ -79,12 +108,27 @@
|
|||||||
--bg-primary: #eff0f2;
|
--bg-primary: #eff0f2;
|
||||||
--bg-secondary: #f5f6f8;
|
--bg-secondary: #f5f6f8;
|
||||||
--bg-tertiary: #eaeaee;
|
--bg-tertiary: #eaeaee;
|
||||||
|
--bg-surface: #ffffff;
|
||||||
--bg-hover: #e2e3e8;
|
--bg-hover: #e2e3e8;
|
||||||
|
--bg-active: #d8dae0;
|
||||||
--bg-card: #f8f8fa;
|
--bg-card: #f8f8fa;
|
||||||
--text-primary: #2c3038;
|
--text-primary: #2c3038;
|
||||||
--text-secondary: #585c68;
|
--text-secondary: #585c68;
|
||||||
|
--text-muted: #787c88;
|
||||||
|
--text-dim: #989ca8;
|
||||||
|
--text-on-accent: #ffffff;
|
||||||
|
--input-text: #2c3038;
|
||||||
|
--input-bg: #ffffff;
|
||||||
|
--input-border: #c8cad0;
|
||||||
--accent: #6878a0;
|
--accent: #6878a0;
|
||||||
|
--accent-glow: rgba(104, 120, 160, 0.15);
|
||||||
|
--accent-light: #8898b8;
|
||||||
|
--accent-dark: #506080;
|
||||||
|
--green: #508860;
|
||||||
|
--orange: #a08050;
|
||||||
|
--red: #a05050;
|
||||||
--border: #d8dae0;
|
--border: #d8dae0;
|
||||||
|
--border-light: #c8cad0;
|
||||||
--editor-bg: #f8f9fc;
|
--editor-bg: #f8f9fc;
|
||||||
--editor-text: #2c3038;
|
--editor-text: #2c3038;
|
||||||
}
|
}
|
||||||
@@ -93,12 +137,27 @@
|
|||||||
--bg-primary: #eef0e4;
|
--bg-primary: #eef0e4;
|
||||||
--bg-secondary: #f4f6ec;
|
--bg-secondary: #f4f6ec;
|
||||||
--bg-tertiary: #e6e8d8;
|
--bg-tertiary: #e6e8d8;
|
||||||
|
--bg-surface: #fafcf4;
|
||||||
--bg-hover: #dee2d0;
|
--bg-hover: #dee2d0;
|
||||||
|
--bg-active: #d4d8c8;
|
||||||
--bg-card: #f2f4e8;
|
--bg-card: #f2f4e8;
|
||||||
--text-primary: #2d3a28;
|
--text-primary: #2d3a28;
|
||||||
--text-secondary: #4a5840;
|
--text-secondary: #4a5840;
|
||||||
|
--text-muted: #6a7860;
|
||||||
|
--text-dim: #8a9880;
|
||||||
|
--text-on-accent: #ffffff;
|
||||||
|
--input-text: #2d3a28;
|
||||||
|
--input-bg: #ffffff;
|
||||||
|
--input-border: #c0c8b0;
|
||||||
--accent: #688848;
|
--accent: #688848;
|
||||||
|
--accent-glow: rgba(104, 136, 72, 0.15);
|
||||||
|
--accent-light: #88a868;
|
||||||
|
--accent-dark: #506838;
|
||||||
|
--green: #508848;
|
||||||
|
--orange: #908050;
|
||||||
|
--red: #985848;
|
||||||
--border: #d0d8c0;
|
--border: #d0d8c0;
|
||||||
|
--border-light: #c0c8b0;
|
||||||
--editor-bg: #f6faf0;
|
--editor-bg: #f6faf0;
|
||||||
--editor-text: #2d3a28;
|
--editor-text: #2d3a28;
|
||||||
}
|
}
|
||||||
@@ -107,13 +166,20 @@
|
|||||||
--bg-primary: #1f1a16;
|
--bg-primary: #1f1a16;
|
||||||
--bg-secondary: #241e1a;
|
--bg-secondary: #241e1a;
|
||||||
--bg-tertiary: #2a2420;
|
--bg-tertiary: #2a2420;
|
||||||
|
--bg-surface: #2e2824;
|
||||||
--bg-hover: #342e28;
|
--bg-hover: #342e28;
|
||||||
|
--bg-active: #3a3428;
|
||||||
--bg-card: #26201c;
|
--bg-card: #26201c;
|
||||||
--text-primary: #e8dcc8;
|
--text-primary: #e8dcc8;
|
||||||
--text-secondary: #b8a890;
|
--text-secondary: #b8a890;
|
||||||
|
--text-muted: #887868;
|
||||||
|
--text-dim: #685848;
|
||||||
--accent: #c88850;
|
--accent: #c88850;
|
||||||
|
--accent-glow: rgba(200, 136, 80, 0.25);
|
||||||
--accent-light: #d8a870;
|
--accent-light: #d8a870;
|
||||||
|
--accent-dark: #a87040;
|
||||||
--border: #383028;
|
--border: #383028;
|
||||||
|
--border-light: #484038;
|
||||||
--editor-bg: #1f1a16;
|
--editor-bg: #1f1a16;
|
||||||
--editor-text: #e8dcc8;
|
--editor-text: #e8dcc8;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+125
-1
@@ -1,13 +1,24 @@
|
|||||||
import type {
|
import type {
|
||||||
ActionId,
|
ActionId,
|
||||||
|
Bookmark,
|
||||||
BookMeta,
|
BookMeta,
|
||||||
BookOpenResult,
|
BookOpenResult,
|
||||||
Chapter,
|
Chapter,
|
||||||
CreateBookParams,
|
CreateBookParams,
|
||||||
GlobalSettings,
|
GlobalSettings,
|
||||||
|
InspirationEntry,
|
||||||
IpcResult,
|
IpcResult,
|
||||||
|
LandmarkType,
|
||||||
|
OutlineItem,
|
||||||
|
OutlineStatus,
|
||||||
|
SearchResult,
|
||||||
|
SettingEntry,
|
||||||
|
SettingType,
|
||||||
|
Snapshot,
|
||||||
|
SnapshotType,
|
||||||
UpdateChapterParams,
|
UpdateChapterParams,
|
||||||
Volume
|
Volume,
|
||||||
|
WordFreqResult
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
@@ -45,6 +56,119 @@ export interface ElectronAPI {
|
|||||||
update: (params: UpdateChapterParams) => Promise<IpcResult<Chapter>>
|
update: (params: UpdateChapterParams) => Promise<IpcResult<Chapter>>
|
||||||
delete: (bookId: string, chapterId: string) => Promise<IpcResult<void>>
|
delete: (bookId: string, chapterId: string) => Promise<IpcResult<void>>
|
||||||
}
|
}
|
||||||
|
outline: {
|
||||||
|
list: (bookId: string) => Promise<IpcResult<OutlineItem[]>>
|
||||||
|
create: (
|
||||||
|
bookId: string,
|
||||||
|
title: string,
|
||||||
|
parentId?: string | null,
|
||||||
|
sortOrder?: number
|
||||||
|
) => Promise<IpcResult<OutlineItem>>
|
||||||
|
update: (
|
||||||
|
bookId: string,
|
||||||
|
id: string,
|
||||||
|
patch: Partial<{
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
status: OutlineStatus
|
||||||
|
expectedWordCount: number | null
|
||||||
|
chapterId: string | null
|
||||||
|
sortOrder: number
|
||||||
|
}>
|
||||||
|
) => Promise<IpcResult<OutlineItem>>
|
||||||
|
delete: (bookId: string, id: string) => Promise<IpcResult<void>>
|
||||||
|
move: (
|
||||||
|
bookId: string,
|
||||||
|
id: string,
|
||||||
|
parentId: string | null,
|
||||||
|
sortOrder: number
|
||||||
|
) => Promise<IpcResult<OutlineItem>>
|
||||||
|
}
|
||||||
|
setting: {
|
||||||
|
list: (bookId: string, type?: SettingType) => Promise<IpcResult<SettingEntry[]>>
|
||||||
|
create: (bookId: string, type: SettingType, name: string) => Promise<IpcResult<SettingEntry>>
|
||||||
|
update: (
|
||||||
|
bookId: string,
|
||||||
|
id: string,
|
||||||
|
patch: Partial<{ name: string; description: string; type: SettingType; properties: Record<string, unknown> }>
|
||||||
|
) => Promise<IpcResult<SettingEntry>>
|
||||||
|
delete: (bookId: string, id: string) => Promise<IpcResult<void>>
|
||||||
|
syncRefs: (bookId: string, id: string, chapterIds: string[]) => Promise<IpcResult<SettingEntry>>
|
||||||
|
}
|
||||||
|
inspiration: {
|
||||||
|
list: (bookId: string) => Promise<IpcResult<InspirationEntry[]>>
|
||||||
|
create: (
|
||||||
|
bookId: string,
|
||||||
|
title?: string,
|
||||||
|
content?: string,
|
||||||
|
tags?: string[]
|
||||||
|
) => Promise<IpcResult<InspirationEntry>>
|
||||||
|
update: (
|
||||||
|
bookId: string,
|
||||||
|
id: string,
|
||||||
|
patch: Partial<{ title: string; content: string; tags: string[] }>
|
||||||
|
) => Promise<IpcResult<InspirationEntry>>
|
||||||
|
delete: (bookId: string, id: string) => Promise<IpcResult<void>>
|
||||||
|
convert: (
|
||||||
|
bookId: string,
|
||||||
|
id: string,
|
||||||
|
targetKind: 'outline' | 'setting',
|
||||||
|
settingType?: SettingType
|
||||||
|
) => Promise<IpcResult<OutlineItem | SettingEntry>>
|
||||||
|
}
|
||||||
|
snapshot: {
|
||||||
|
list: (bookId: string, chapterId: string) => Promise<IpcResult<Snapshot[]>>
|
||||||
|
create: (
|
||||||
|
bookId: string,
|
||||||
|
chapterId: string,
|
||||||
|
type: SnapshotType,
|
||||||
|
name?: string,
|
||||||
|
content?: string
|
||||||
|
) => Promise<IpcResult<Snapshot>>
|
||||||
|
restore: (bookId: string, chapterId: string, snapshotId: string) => Promise<IpcResult<Chapter>>
|
||||||
|
delete: (bookId: string, snapshotId: string) => Promise<IpcResult<void>>
|
||||||
|
startAutoSave: (bookId: string, chapterId: string) => Promise<IpcResult<void>>
|
||||||
|
stopAutoSave: (bookId: string, chapterId: string) => Promise<IpcResult<void>>
|
||||||
|
}
|
||||||
|
bookmark: {
|
||||||
|
list: (bookId: string, chapterId?: string, resolved?: boolean) => Promise<IpcResult<Bookmark[]>>
|
||||||
|
create: (
|
||||||
|
bookId: string,
|
||||||
|
chapterId: string,
|
||||||
|
position: number,
|
||||||
|
label: string,
|
||||||
|
landmarkType?: LandmarkType
|
||||||
|
) => Promise<IpcResult<Bookmark>>
|
||||||
|
update: (
|
||||||
|
bookId: string,
|
||||||
|
id: string,
|
||||||
|
patch: Partial<{ label: string; position: number; landmarkType: LandmarkType }>
|
||||||
|
) => Promise<IpcResult<Bookmark>>
|
||||||
|
resolve: (bookId: string, id: string, resolved: boolean) => Promise<IpcResult<Bookmark>>
|
||||||
|
delete: (bookId: string, id: string) => Promise<IpcResult<void>>
|
||||||
|
}
|
||||||
|
search: {
|
||||||
|
query: (
|
||||||
|
bookId: string,
|
||||||
|
query: string,
|
||||||
|
options?: { regex?: boolean; caseSensitive?: boolean; matchWholeWord?: boolean }
|
||||||
|
) => Promise<IpcResult<SearchResult[]>>
|
||||||
|
replace: (
|
||||||
|
bookId: string,
|
||||||
|
query: string,
|
||||||
|
replacement: string,
|
||||||
|
dryRun: boolean,
|
||||||
|
options?: { regex?: boolean; caseSensitive?: boolean }
|
||||||
|
) => Promise<IpcResult<{ count: number; previews: SearchResult[] }>>
|
||||||
|
getHistory: () => Promise<IpcResult<string[]>>
|
||||||
|
saveHistory: (query: string) => Promise<IpcResult<string[]>>
|
||||||
|
}
|
||||||
|
wordfreq: {
|
||||||
|
analyze: (
|
||||||
|
bookId: string,
|
||||||
|
scope: { kind: 'book' | 'volume' | 'chapter'; chapterId?: string; volumeId?: string }
|
||||||
|
) => Promise<IpcResult<WordFreqResult>>
|
||||||
|
}
|
||||||
shortcut: {
|
shortcut: {
|
||||||
getAll: () => Promise<IpcResult<Record<ActionId, string>>>
|
getAll: () => Promise<IpcResult<Record<ActionId, string>>>
|
||||||
register: (action: ActionId, accelerator: string) => Promise<IpcResult<void>>
|
register: (action: ActionId, accelerator: string) => Promise<IpcResult<void>>
|
||||||
|
|||||||
@@ -18,5 +18,36 @@ export const IPC = {
|
|||||||
SHORTCUT_TRIGGERED: 'shortcut:triggered',
|
SHORTCUT_TRIGGERED: 'shortcut:triggered',
|
||||||
WINDOW_MINIMIZE: 'window:minimize',
|
WINDOW_MINIMIZE: 'window:minimize',
|
||||||
WINDOW_MAXIMIZE: 'window:maximize',
|
WINDOW_MAXIMIZE: 'window:maximize',
|
||||||
WINDOW_CLOSE: 'window:close'
|
WINDOW_CLOSE: 'window:close',
|
||||||
|
OUTLINE_LIST: 'outline:list',
|
||||||
|
OUTLINE_CREATE: 'outline:create',
|
||||||
|
OUTLINE_UPDATE: 'outline:update',
|
||||||
|
OUTLINE_DELETE: 'outline:delete',
|
||||||
|
OUTLINE_MOVE: 'outline:move',
|
||||||
|
SETTING_LIST: 'setting:list',
|
||||||
|
SETTING_CREATE: 'setting:create',
|
||||||
|
SETTING_UPDATE: 'setting:update',
|
||||||
|
SETTING_DELETE: 'setting:delete',
|
||||||
|
SETTING_SYNC_REFS: 'setting:syncRefs',
|
||||||
|
INSPIRATION_LIST: 'inspiration:list',
|
||||||
|
INSPIRATION_CREATE: 'inspiration:create',
|
||||||
|
INSPIRATION_UPDATE: 'inspiration:update',
|
||||||
|
INSPIRATION_DELETE: 'inspiration:delete',
|
||||||
|
INSPIRATION_CONVERT: 'inspiration:convert',
|
||||||
|
SNAPSHOT_LIST: 'snapshot:list',
|
||||||
|
SNAPSHOT_CREATE: 'snapshot:create',
|
||||||
|
SNAPSHOT_RESTORE: 'snapshot:restore',
|
||||||
|
SNAPSHOT_DELETE: 'snapshot:delete',
|
||||||
|
SNAPSHOT_START_AUTO: 'snapshot:startAuto',
|
||||||
|
SNAPSHOT_STOP_AUTO: 'snapshot:stopAuto',
|
||||||
|
BOOKMARK_LIST: 'bookmark:list',
|
||||||
|
BOOKMARK_CREATE: 'bookmark:create',
|
||||||
|
BOOKMARK_UPDATE: 'bookmark:update',
|
||||||
|
BOOKMARK_RESOLVE: 'bookmark:resolve',
|
||||||
|
BOOKMARK_DELETE: 'bookmark:delete',
|
||||||
|
SEARCH_QUERY: 'search:query',
|
||||||
|
SEARCH_REPLACE: 'search:replace',
|
||||||
|
SEARCH_GET_HISTORY: 'search:getHistory',
|
||||||
|
SEARCH_SAVE_HISTORY: 'search:saveHistory',
|
||||||
|
WORDFREQ_ANALYZE: 'wordfreq:analyze'
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ export type ThemeId =
|
|||||||
export type Language = 'zh-CN' | 'en'
|
export type Language = 'zh-CN' | 'en'
|
||||||
export type ChapterStatus = 'draft' | 'review' | 'done'
|
export type ChapterStatus = 'draft' | 'review' | 'done'
|
||||||
export type BookStatus = 'draft' | 'ongoing' | 'done'
|
export type BookStatus = 'draft' | 'ongoing' | 'done'
|
||||||
|
export type PublishStatus = 'draft' | 'ready' | 'published'
|
||||||
|
export type OutlineStatus = 'pending' | 'writing' | 'done' | 'abandoned'
|
||||||
|
export type SettingType = 'character' | 'location' | 'item' | 'skill' | 'faction' | 'custom'
|
||||||
|
export type SnapshotType = 'auto' | 'manual' | 'persist' | 'ai'
|
||||||
|
export type LandmarkType = 'todo' | 'foreshadow' | 'research' | 'custom'
|
||||||
|
export type EditTargetKind = 'chapter' | 'outline' | 'setting' | 'inspiration'
|
||||||
|
export type EditTarget = { kind: EditTargetKind; id: string }
|
||||||
|
export type SearchScope = 'all' | 'volume' | 'chapter'
|
||||||
|
export type SearchSourceKind = 'chapter' | 'outline' | 'setting' | 'inspiration' | 'bookmark'
|
||||||
|
|
||||||
export type ActionId =
|
export type ActionId =
|
||||||
| 'saveChapter'
|
| 'saveChapter'
|
||||||
@@ -47,6 +56,10 @@ export interface GlobalSettings {
|
|||||||
language: Language
|
language: Language
|
||||||
dailyWordGoal: number
|
dailyWordGoal: number
|
||||||
shortcuts: Record<ActionId, string>
|
shortcuts: Record<ActionId, string>
|
||||||
|
snapshotIntervalMs: number
|
||||||
|
snapshotMaxAuto: number
|
||||||
|
snapshotMaxPersist: number
|
||||||
|
searchHistory: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookMeta {
|
export interface BookMeta {
|
||||||
@@ -84,12 +97,91 @@ export interface Chapter {
|
|||||||
wordCount: number
|
wordCount: number
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
cursorOffset: number
|
cursorOffset: number
|
||||||
|
publishStatus?: PublishStatus
|
||||||
|
povCharacterId?: string | null
|
||||||
|
summary?: string
|
||||||
|
storyTime?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutlineItem {
|
||||||
|
id: string
|
||||||
|
parentId: string | null
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
status: OutlineStatus
|
||||||
|
expectedWordCount: number | null
|
||||||
|
chapterId: string | null
|
||||||
|
sortOrder: number
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
children?: OutlineItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingEntry {
|
||||||
|
id: string
|
||||||
|
type: SettingType
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
properties: Record<string, unknown>
|
||||||
|
chapterIds?: string[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspirationEntry {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
tags: string[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Snapshot {
|
||||||
|
id: string
|
||||||
|
chapterId: string
|
||||||
|
type: SnapshotType
|
||||||
|
name: string
|
||||||
|
content: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Bookmark {
|
||||||
|
id: string
|
||||||
|
chapterId: string
|
||||||
|
position: number
|
||||||
|
label: string
|
||||||
|
landmarkType: LandmarkType
|
||||||
|
resolved: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchOptions {
|
||||||
|
regex?: boolean
|
||||||
|
caseSensitive?: boolean
|
||||||
|
matchWholeWord?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
kind: SearchSourceKind
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
snippet: string
|
||||||
|
chapterId?: string
|
||||||
|
position?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WordFreqResult {
|
||||||
|
words: { token: string; count: number }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookOpenResult {
|
export interface BookOpenResult {
|
||||||
meta: BookMeta
|
meta: BookMeta
|
||||||
volumes: Volume[]
|
volumes: Volume[]
|
||||||
chapters: Chapter[]
|
chapters: Chapter[]
|
||||||
|
outlines: OutlineItem[]
|
||||||
|
settings: SettingEntry[]
|
||||||
|
inspirations: InspirationEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateChapterParams {
|
export interface UpdateChapterParams {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { BookmarkRepository } from '../../src/main/db/repositories/bookmark.repo'
|
||||||
|
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||||
|
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||||
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
|
||||||
|
describe('BookmarkRepository', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
let repo: BookmarkRepository
|
||||||
|
let chapterId: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
repo = new BookmarkRepository(db)
|
||||||
|
const vol = new VolumeRepository(db).create('卷一', 0)
|
||||||
|
chapterId = new ChapterRepository(db).create(vol.id, '第一章', 0).id
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
db.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates and lists bookmarks by chapter', () => {
|
||||||
|
repo.create(chapterId, 100, '待办:补充描写', 'todo')
|
||||||
|
expect(repo.listByChapter(chapterId)).toHaveLength(1)
|
||||||
|
expect(repo.listByChapter(chapterId)[0].label).toBe('待办:补充描写')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves bookmark', () => {
|
||||||
|
const bm = repo.create(chapterId, 50, '伏笔', 'foreshadow')
|
||||||
|
repo.resolve(bm.id, true)
|
||||||
|
expect(repo.get(bm.id)!.resolved).toBe(true)
|
||||||
|
expect(repo.list(undefined, false)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes bookmark', () => {
|
||||||
|
const bm = repo.create(chapterId, 0, '研究笔记', 'research')
|
||||||
|
repo.delete(bm.id)
|
||||||
|
expect(repo.get(bm.id)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { InspirationRepository } from '../../src/main/db/repositories/inspiration.repo'
|
||||||
|
import { OutlineRepository } from '../../src/main/db/repositories/outline.repo'
|
||||||
|
import { SettingRepository } from '../../src/main/db/repositories/setting.repo'
|
||||||
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
|
||||||
|
describe('InspirationRepository', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
let repo: InspirationRepository
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
repo = new InspirationRepository(db)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
db.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates and lists inspirations', () => {
|
||||||
|
repo.create('标题', '内容', ['tag1'])
|
||||||
|
expect(repo.list()).toHaveLength(1)
|
||||||
|
expect(repo.list()[0].tags).toEqual(['tag1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts to outline', () => {
|
||||||
|
const item = repo.create('灵感标题', '<p>灵感正文</p>')
|
||||||
|
const outlineRepo = new OutlineRepository(db)
|
||||||
|
const outline = repo.convertToOutline(item.id, outlineRepo)
|
||||||
|
expect(repo.get(item.id)).toBeNull()
|
||||||
|
expect(outline.title).toBe('灵感标题')
|
||||||
|
expect(outline.description).toContain('灵感正文')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts to setting', () => {
|
||||||
|
const item = repo.create('角色灵感', '外貌描述')
|
||||||
|
const settingRepo = new SettingRepository(db)
|
||||||
|
const setting = repo.convertToSetting(item.id, 'character', settingRepo)
|
||||||
|
expect(repo.get(item.id)).toBeNull()
|
||||||
|
expect(setting.type).toBe('character')
|
||||||
|
expect(setting.name).toBe('角色灵感')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import schemaV1 from '../../src/main/db/schema-v1.sql?raw'
|
||||||
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
|
||||||
|
function tableExists(db: SqliteDb, name: string): boolean {
|
||||||
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name)
|
||||||
|
return row != null
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('migrate v2', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
db?.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies v1 and v2 on fresh database', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
|
||||||
|
expect(tableExists(db, 'volumes')).toBe(true)
|
||||||
|
expect(tableExists(db, 'chapters')).toBe(true)
|
||||||
|
expect(tableExists(db, 'outline_items')).toBe(true)
|
||||||
|
expect(tableExists(db, 'settings')).toBe(true)
|
||||||
|
expect(tableExists(db, 'inspiration')).toBe(true)
|
||||||
|
expect(tableExists(db, 'snapshots')).toBe(true)
|
||||||
|
expect(tableExists(db, 'bookmarks')).toBe(true)
|
||||||
|
expect(tableExists(db, 'search_fts')).toBe(true)
|
||||||
|
|
||||||
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
|
expect(version.v).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('migrates existing v1 database to v2', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
description TEXT
|
||||||
|
)`)
|
||||||
|
db.exec(schemaV1)
|
||||||
|
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(1, 'initial schema')
|
||||||
|
|
||||||
|
migrate(db)
|
||||||
|
|
||||||
|
expect(tableExists(db, 'outline_items')).toBe(true)
|
||||||
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
|
expect(version.v).toBe(2)
|
||||||
|
|
||||||
|
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
|
||||||
|
const colNames = cols.map((c) => c.name)
|
||||||
|
expect(colNames).toContain('publish_status')
|
||||||
|
expect(colNames).toContain('summary')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { OutlineRepository } from '../../src/main/db/repositories/outline.repo'
|
||||||
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
|
||||||
|
describe('OutlineRepository', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
let repo: OutlineRepository
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
repo = new OutlineRepository(db)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
db.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates nested outline items', () => {
|
||||||
|
const parent = repo.create(null, '第一幕', 0)
|
||||||
|
const child = repo.create(parent.id, '开端', 0)
|
||||||
|
expect(repo.listTree()).toHaveLength(1)
|
||||||
|
expect(repo.listTree()[0].children).toHaveLength(1)
|
||||||
|
expect(repo.listTree()[0].children![0].id).toBe(child.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists uncovered items without chapter link', () => {
|
||||||
|
const item = repo.create(null, '未覆盖', 0)
|
||||||
|
repo.update(item.id, { chapterId: null })
|
||||||
|
expect(repo.listUncovered()).toHaveLength(1)
|
||||||
|
expect(repo.listUncovered()[0].id).toBe(item.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes parent and cascades children', () => {
|
||||||
|
const parent = repo.create(null, '父节点', 0)
|
||||||
|
repo.create(parent.id, '子节点', 0)
|
||||||
|
repo.delete(parent.id)
|
||||||
|
expect(repo.listFlat()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves item to new parent', () => {
|
||||||
|
const a = repo.create(null, 'A', 0)
|
||||||
|
const b = repo.create(null, 'B', 1)
|
||||||
|
const child = repo.create(a.id, 'child', 0)
|
||||||
|
repo.move(child.id, b.id, 0)
|
||||||
|
expect(repo.get(child.id)!.parentId).toBe(b.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||||
|
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||||
|
import { ftsSync } from '../../src/main/services/fts-sync.service'
|
||||||
|
import { searchService } from '../../src/main/services/search.service'
|
||||||
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
|
||||||
|
describe('SearchService', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const vol = new VolumeRepository(db).create('卷一', 0)
|
||||||
|
const ch = new ChapterRepository(db).create(vol.id, '第一章', 0, '林远走进青云山')
|
||||||
|
ftsSync.upsert(db, 'chapter', ch.id, ch.title, ch.content)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
db.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds chapter by FTS query', () => {
|
||||||
|
const results = searchService.query(db, { text: '青云山' })
|
||||||
|
expect(results.length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(results[0].kind).toBe('chapter')
|
||||||
|
expect(results[0].snippet).toContain('青云')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns dry-run replace preview', () => {
|
||||||
|
const { count, previews } = searchService.replace(db, {
|
||||||
|
text: '林远',
|
||||||
|
replacement: '李明',
|
||||||
|
dryRun: true
|
||||||
|
})
|
||||||
|
expect(count).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(previews.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports regex fallback search', () => {
|
||||||
|
const results = searchService.query(db, { text: '林.+山', regex: true })
|
||||||
|
expect(results.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { SettingRepository } from '../../src/main/db/repositories/setting.repo'
|
||||||
|
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||||
|
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||||
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
|
||||||
|
describe('SettingRepository', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
let repo: SettingRepository
|
||||||
|
let chapterId: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
repo = new SettingRepository(db)
|
||||||
|
const vol = new VolumeRepository(db).create('卷一', 0)
|
||||||
|
chapterId = new ChapterRepository(db).create(vol.id, '第一章', 0).id
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
db.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates and lists settings by type', () => {
|
||||||
|
repo.create('character', '林远')
|
||||||
|
repo.create('location', '青云山')
|
||||||
|
expect(repo.list('character')).toHaveLength(1)
|
||||||
|
expect(repo.list('character')[0].name).toBe('林远')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncs chapter refs', () => {
|
||||||
|
const setting = repo.create('character', '主角')
|
||||||
|
repo.syncChapterRefs(setting.id, [chapterId])
|
||||||
|
const updated = repo.get(setting.id)!
|
||||||
|
expect(updated.chapterIds).toEqual([chapterId])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates and deletes setting', () => {
|
||||||
|
const setting = repo.create('character', '旧名')
|
||||||
|
repo.update(setting.id, { name: '新名' })
|
||||||
|
expect(repo.get(setting.id)!.name).toBe('新名')
|
||||||
|
repo.delete(setting.id)
|
||||||
|
expect(repo.get(setting.id)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { SnapshotRepository } from '../../src/main/db/repositories/snapshot.repo'
|
||||||
|
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||||
|
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||||
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
|
||||||
|
describe('SnapshotRepository', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
let repo: SnapshotRepository
|
||||||
|
let chapterId: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
repo = new SnapshotRepository(db)
|
||||||
|
const vol = new VolumeRepository(db).create('卷一', 0)
|
||||||
|
chapterId = new ChapterRepository(db).create(vol.id, '第一章', 0, '初始内容').id
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
db.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates and lists snapshots', () => {
|
||||||
|
repo.create(chapterId, 'manual', '版本一', '手动保存')
|
||||||
|
expect(repo.list(chapterId)).toHaveLength(1)
|
||||||
|
expect(repo.list(chapterId)[0].name).toBe('手动保存')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prunes oldest auto snapshots', () => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
repo.create(chapterId, 'auto', `content-${i}`)
|
||||||
|
}
|
||||||
|
repo.pruneAuto(chapterId, 3)
|
||||||
|
const autos = repo.list(chapterId).filter((s) => s.type === 'auto')
|
||||||
|
expect(autos).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores snapshot content', () => {
|
||||||
|
const snap = repo.create(chapterId, 'manual', '可恢复内容')
|
||||||
|
expect(repo.restore(snap.id)).toBe('可恢复内容')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||||
|
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||||
|
import { wordFreqService } from '../../src/main/services/wordfreq.service'
|
||||||
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
|
||||||
|
describe('WordFreqService', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const vol = new VolumeRepository(db).create('卷一', 0)
|
||||||
|
new ChapterRepository(db).create(
|
||||||
|
vol.id,
|
||||||
|
'第一章',
|
||||||
|
0,
|
||||||
|
'<p>林远走进青云山。The hero walked slowly.</p>'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
db.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts CJK characters and latin words', () => {
|
||||||
|
const result = wordFreqService.analyze(db, { kind: 'book' })
|
||||||
|
expect(result.words.length).toBeGreaterThan(0)
|
||||||
|
const tokens = result.words.map((w) => w.token)
|
||||||
|
expect(tokens).toContain('林')
|
||||||
|
expect(tokens).toContain('the')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('analyzes single chapter scope', () => {
|
||||||
|
const ch = (db.prepare('SELECT id FROM chapters LIMIT 1').get() as { id: string }).id
|
||||||
|
const result = wordFreqService.analyze(db, { kind: 'chapter', chapterId: ch })
|
||||||
|
expect(result.words.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user