feat(w5): ship v2.0.0 platform sync, undo persistence, and updates
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,14 @@
|
|||||||
# 笔临 (Bilin)
|
# 笔临 (Bilin)
|
||||||
|
|
||||||
长篇创作智能协作平台 — Electron 桌面客户端 v1.5.0(创作深化)
|
长篇创作智能协作平台 — Electron 桌面客户端 v2.0.0(平台基建)
|
||||||
|
|
||||||
|
## 功能概览(v2.0.0)
|
||||||
|
|
||||||
|
- 撤销堆栈持久化:每章最多 50 条,跨会话 Ctrl+Z
|
||||||
|
- 云同步:加密 `.novel-all` → WebDAV / 本地目录
|
||||||
|
- 崩溃日志:`electron-log` + 异常退出提示
|
||||||
|
- 自动更新:`electron-updater`(E2E 模式禁用)
|
||||||
|
- 扩展 API 骨架、内置 Skill / MCP 占位
|
||||||
|
|
||||||
## 功能概览(v1.5.0)
|
## 功能概览(v1.5.0)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 笔临扩展开发指南
|
||||||
|
|
||||||
|
笔临 v2.0.0 提供 **扩展 API 骨架**,用于声明第三方扩展点。当前版本**不加载或执行**第三方代码,仅校验 `manifest` 并展示占位列表。
|
||||||
|
|
||||||
|
## BilinExtension 清单
|
||||||
|
|
||||||
|
在扩展包的 `bilin-extension.json` 中声明:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "示例扩展",
|
||||||
|
"main": "index.js",
|
||||||
|
"permissions": ["filesystem.read"],
|
||||||
|
"contributes": {
|
||||||
|
"importers": [{ "id": "myfmt", "extensions": [".mybook"], "label": "My Format" }],
|
||||||
|
"exporters": [{ "id": "myexport", "extensions": [".mybook"], "label": "Export My Format" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 扩展点
|
||||||
|
|
||||||
|
| 扩展点 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| `sidePanels` | 侧栏自定义面板 |
|
||||||
|
| `importers` | 自定义导入格式 |
|
||||||
|
| `exporters` | 自定义导出格式 |
|
||||||
|
| `aiBackends` | 可选 AI 后端 |
|
||||||
|
| `skills` | AI Skill |
|
||||||
|
| `editorExtensions` | TipTap 节点/标记 |
|
||||||
|
| `menuItems` | 工具栏/右键菜单 |
|
||||||
|
|
||||||
|
## 安全
|
||||||
|
|
||||||
|
- 扩展默认沙箱运行,需声明 `permissions`
|
||||||
|
- 敏感权限需用户在设置中显式授权(后续版本)
|
||||||
|
- 扩展仅能通过笔临提供的 API 与核心交互
|
||||||
|
|
||||||
|
## 开发者模式
|
||||||
|
|
||||||
|
设置 → 扩展 → 开启「开发者模式」后,可从本地文件夹读取 manifest 做校验(不执行 `main`)。
|
||||||
|
|
||||||
|
## 相关类型
|
||||||
|
|
||||||
|
见 `src/shared/extension-api.ts` 与 `src/main/services/extension-registry.ts`。
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 笔临 Wave 5 平台基建 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 交付 v2.0.0:撤销堆栈持久化、云同步、崩溃日志、自动更新、扩展 API 骨架、Skill/MCP 占位。
|
||||||
|
|
||||||
|
**Architecture:** Schema v11 `undo_stack`;`SyncService` 加密 `.novel-all`;`electron-log` + `electron-updater`;`ExtensionRegistry` stub。
|
||||||
|
|
||||||
|
**Tech Stack:** Electron 36、electron-log、electron-updater、Vitest、Playwright
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-07-08-bilin-master-completion-design.md` §9
|
||||||
|
|
||||||
|
**Master Program:** Wave 5/5 → v2.0.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `src/main/db/schema-v11.sql` | undo_stack |
|
||||||
|
| `src/main/services/undo-stack.service.ts` | 持久化撤销 |
|
||||||
|
| `src/main/services/sync.service.ts` | 本地/WebDAV 同步 |
|
||||||
|
| `src/main/services/log.service.ts` | electron-log |
|
||||||
|
| `src/main/services/update.service.ts` | electron-updater |
|
||||||
|
| `src/main/services/extension-registry.ts` | 扩展注册表 stub |
|
||||||
|
| `src/shared/extension-api.ts` | BilinExtension 类型 |
|
||||||
|
| `src/renderer/components/settings/SyncSettingsTab.tsx` | 备份与同步 |
|
||||||
|
| `e2e/timeline.spec.ts` | E2E-TL-01~03 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Schema v11 + 撤销堆栈
|
||||||
|
|
||||||
|
- [x] **Step 1:** schema-v11 + migrate v11
|
||||||
|
- [x] **Step 2:** UndoStackRepository + Service + IPC
|
||||||
|
- [x] **Step 3:** TipTap 防抖持久化 + 跨会话 Ctrl+Z
|
||||||
|
- [x] **Step 4:** E2E-UNDO-01
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: 云同步
|
||||||
|
|
||||||
|
- [x] **Step 1:** sync-crypto AES-256-GCM
|
||||||
|
- [x] **Step 2:** SyncService local-folder + WebDAV
|
||||||
|
- [x] **Step 3:** SyncSettingsTab + 冲突模态
|
||||||
|
- [x] **Step 4:** IT-SYNC-01 + E2E-SYNC-01
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: 日志与崩溃报告
|
||||||
|
|
||||||
|
- [x] **Step 1:** LogService + 启动检测
|
||||||
|
- [x] **Step 2:** 发送报告 stub + UT-LOG-01
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: 自动更新
|
||||||
|
|
||||||
|
- [x] **Step 1:** UpdateService + IPC 事件
|
||||||
|
- [x] **Step 2:** 顶栏更新提示(BILIN_E2E 禁用 / BILIN_MOCK_UPDATE mock)
|
||||||
|
- [x] **Step 3:** IT-UPDATE-01 + E2E-UPDATE-02
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: 扩展 API + Skill/MCP
|
||||||
|
|
||||||
|
- [x] **Step 1:** extension-api.ts + registry stub
|
||||||
|
- [x] **Step 2:** docs/extensions/README.md
|
||||||
|
- [x] **Step 3:** ExtensionsSettingsTab + AI Skill/MCP 占位
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: i18n + v2.0.0
|
||||||
|
|
||||||
|
- [x] **Step 1:** app.getLocale 首次语言
|
||||||
|
- [x] **Step 2:** 全量 test + E2E
|
||||||
|
- [x] **Step 3:** 版本 **v2.0.0**
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import {
|
||||||
|
launchApp,
|
||||||
|
skipOnboarding,
|
||||||
|
createBookAndOpenEditor,
|
||||||
|
ensureChapterEditor,
|
||||||
|
dismissCockpitIfOpen
|
||||||
|
} from './helpers'
|
||||||
|
|
||||||
|
test.describe('Cloud sync', () => {
|
||||||
|
let userDataDir: string
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-sync-'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterEach(() => {
|
||||||
|
if (userDataDir && existsSync(userDataDir)) {
|
||||||
|
try {
|
||||||
|
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-SYNC-01: configure local sync and run', async () => {
|
||||||
|
const app = await launchApp(userDataDir)
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await page.getByRole('button', { name: /系统设置/ }).click()
|
||||||
|
await expect(page.locator('#settings-page')).toBeVisible({ timeout: 10_000 })
|
||||||
|
await page.getByTestId('settings-nav-backup').click()
|
||||||
|
await expect(page.getByTestId('sync-settings-tab')).toBeVisible({ timeout: 10_000 })
|
||||||
|
|
||||||
|
const syncDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-sync-dir-'))
|
||||||
|
await page.getByTestId('sync-local-path').fill(syncDir)
|
||||||
|
await page.getByTestId('sync-password').fill('e2e-sync-password')
|
||||||
|
await page.getByTestId('sync-save-config').click()
|
||||||
|
await page.getByTestId('sync-run-now').click()
|
||||||
|
await expect(page.getByTestId('sync-progress').or(page.getByTestId('sync-last-status'))).toBeVisible({
|
||||||
|
timeout: 60_000
|
||||||
|
})
|
||||||
|
|
||||||
|
await app.close()
|
||||||
|
rmSync(syncDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import {
|
||||||
|
launchApp,
|
||||||
|
skipOnboarding,
|
||||||
|
createBookAndOpenEditor,
|
||||||
|
ensureChapterEditor,
|
||||||
|
dismissCockpitIfOpen
|
||||||
|
} from './helpers'
|
||||||
|
|
||||||
|
test.describe('Persistent undo', () => {
|
||||||
|
let userDataDir: string
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-undo-'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterEach(() => {
|
||||||
|
if (userDataDir && existsSync(userDataDir)) {
|
||||||
|
try {
|
||||||
|
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-UNDO-01: ctrl+z after reopen restores previous content', async () => {
|
||||||
|
const app = await launchApp(userDataDir)
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await createBookAndOpenEditor(page, '撤销测试书')
|
||||||
|
await dismissCockpitIfOpen(page)
|
||||||
|
await ensureChapterEditor(page)
|
||||||
|
|
||||||
|
const editor = page.locator('.ProseMirror')
|
||||||
|
await editor.click()
|
||||||
|
await editor.pressSequentially('持久化撤销测试')
|
||||||
|
await page.keyboard.press('Control+S')
|
||||||
|
await page.waitForTimeout(2000)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /笔临/ }).click()
|
||||||
|
await expect(page.locator('#home-page')).toBeVisible({ timeout: 15_000 })
|
||||||
|
|
||||||
|
await page.locator('.book-name', { hasText: '撤销测试书' }).click()
|
||||||
|
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await dismissCockpitIfOpen(page)
|
||||||
|
await ensureChapterEditor(page)
|
||||||
|
await expect(editor).toContainText('持久化撤销测试')
|
||||||
|
|
||||||
|
await editor.click()
|
||||||
|
await page.keyboard.press('Control+Z')
|
||||||
|
await expect(editor).not.toContainText('持久化撤销测试', { timeout: 10_000 })
|
||||||
|
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { launchApp, skipOnboarding } from './helpers'
|
||||||
|
|
||||||
|
test.describe('Auto update', () => {
|
||||||
|
let userDataDir: string
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-update-'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterEach(() => {
|
||||||
|
if (userDataDir && existsSync(userDataDir)) {
|
||||||
|
try {
|
||||||
|
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-UPDATE-02: mock update shows banner and download flow', async () => {
|
||||||
|
const app = await launchApp(userDataDir, {
|
||||||
|
BILIN_MOCK_UPDATE: '1',
|
||||||
|
BILIN_MOCK_UPDATE_VERSION: '9.9.9'
|
||||||
|
})
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await expect(page.getByTestId('update-banner')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await page.getByTestId('update-download').click()
|
||||||
|
await expect(page.getByTestId('update-install')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,9 +11,10 @@ export default defineConfig({
|
|||||||
index: resolve(__dirname, 'src/main/index.ts'),
|
index: resolve(__dirname, 'src/main/index.ts'),
|
||||||
'import-bootstrap': resolve(__dirname, 'src/main/import-bootstrap.ts'),
|
'import-bootstrap': resolve(__dirname, 'src/main/import-bootstrap.ts'),
|
||||||
'pack-bootstrap': resolve(__dirname, 'src/main/pack-bootstrap.ts'),
|
'pack-bootstrap': resolve(__dirname, 'src/main/pack-bootstrap.ts'),
|
||||||
'export-matrix-bootstrap': resolve(__dirname, 'src/main/export-matrix-bootstrap.ts')
|
'export-matrix-bootstrap': resolve(__dirname, 'src/main/export-matrix-bootstrap.ts'),
|
||||||
|
'sync-bootstrap': resolve(__dirname, 'src/main/sync-bootstrap.ts')
|
||||||
},
|
},
|
||||||
external: ['mammoth', 'marked', 'archiver', 'extract-zip', 'docx']
|
external: ['mammoth', 'marked', 'archiver', 'extract-zip', 'docx', 'electron-log', 'electron-updater']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+73
-8
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bilin",
|
"name": "bilin",
|
||||||
"version": "1.3.0",
|
"version": "1.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bilin",
|
"name": "bilin",
|
||||||
"version": "1.3.0",
|
"version": "1.5.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-checkbox": "^1.3.6",
|
"@radix-ui/react-checkbox": "^1.3.6",
|
||||||
@@ -24,6 +24,8 @@
|
|||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
"docx": "^9.7.1",
|
"docx": "^9.7.1",
|
||||||
"electron": "^36.9.0",
|
"electron": "^36.9.0",
|
||||||
|
"electron-log": "^5.4.4",
|
||||||
|
"electron-updater": "^6.8.9",
|
||||||
"extract-zip": "^2.0.1",
|
"extract-zip": "^2.0.1",
|
||||||
"i18next": "^24.2.3",
|
"i18next": "^24.2.3",
|
||||||
"jotai": "^2.12.1",
|
"jotai": "^2.12.1",
|
||||||
@@ -3724,7 +3726,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/aria-hidden": {
|
"node_modules/aria-hidden": {
|
||||||
@@ -5369,6 +5370,15 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/electron-log": {
|
||||||
|
"version": "5.4.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/electron-log/-/electron-log-5.4.4.tgz",
|
||||||
|
"integrity": "sha512-istWgaXjBfURBSS8LWVW9C3jsc6+ac+tY1lXrQEOTp0lVj+a4OlO1Tmqb36GgnEUDv92DGC9VI1HNXwJinWpgA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"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",
|
||||||
@@ -5392,6 +5402,47 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/electron-updater": {
|
||||||
|
"version": "6.8.9",
|
||||||
|
"resolved": "https://registry.npmmirror.com/electron-updater/-/electron-updater-6.8.9.tgz",
|
||||||
|
"integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"builder-util-runtime": "9.7.0",
|
||||||
|
"fs-extra": "^10.1.0",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"lazy-val": "^1.0.5",
|
||||||
|
"lodash.escaperegexp": "^4.1.2",
|
||||||
|
"lodash.isequal": "^4.5.0",
|
||||||
|
"semver": "~7.7.3",
|
||||||
|
"tiny-typed-emitter": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/electron-updater/node_modules/builder-util-runtime": {
|
||||||
|
"version": "9.7.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz",
|
||||||
|
"integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"sax": "^1.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/electron-updater/node_modules/semver": {
|
||||||
|
"version": "7.7.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
|
||||||
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-vite": {
|
"node_modules/electron-vite": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-3.1.0.tgz",
|
||||||
@@ -5830,7 +5881,6 @@
|
|||||||
"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",
|
||||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
@@ -6608,7 +6658,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -6677,7 +6726,6 @@
|
|||||||
"version": "6.2.1",
|
"version": "6.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||||
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"universalify": "^2.0.0"
|
"universalify": "^2.0.0"
|
||||||
@@ -6747,7 +6795,6 @@
|
|||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
|
||||||
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
|
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lazystream": {
|
"node_modules/lazystream": {
|
||||||
@@ -6830,6 +6877,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.escaperegexp": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.flatten": {
|
"node_modules/lodash.flatten": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
|
||||||
@@ -6838,6 +6891,13 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.isequal": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||||
|
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.isplainobject": {
|
"node_modules/lodash.isplainobject": {
|
||||||
"version": "4.0.6",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
@@ -8964,6 +9024,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-typed-emitter": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -9130,7 +9196,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bilin",
|
"name": "bilin",
|
||||||
"version": "1.5.0",
|
"version": "2.0.0",
|
||||||
"description": "笔临 - 长篇创作智能协作平台",
|
"description": "笔临 - 长篇创作智能协作平台",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -34,6 +34,8 @@
|
|||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
"docx": "^9.7.1",
|
"docx": "^9.7.1",
|
||||||
"electron": "^36.9.0",
|
"electron": "^36.9.0",
|
||||||
|
"electron-log": "^5.4.4",
|
||||||
|
"electron-updater": "^6.8.9",
|
||||||
"extract-zip": "^2.0.1",
|
"extract-zip": "^2.0.1",
|
||||||
"i18next": "^24.2.3",
|
"i18next": "^24.2.3",
|
||||||
"jotai": "^2.12.1",
|
"jotai": "^2.12.1",
|
||||||
|
|||||||
@@ -589,5 +589,50 @@
|
|||||||
"readMode.fontSerif": "Serif",
|
"readMode.fontSerif": "Serif",
|
||||||
"readMode.fontSans": "Sans-serif",
|
"readMode.fontSans": "Sans-serif",
|
||||||
"readMode.fontEyeCare": "Eye care",
|
"readMode.fontEyeCare": "Eye care",
|
||||||
"readMode.tts": "Read aloud"
|
"readMode.tts": "Read aloud",
|
||||||
|
"settings.extensions": "Extensions",
|
||||||
|
"sync.title": "Cloud sync",
|
||||||
|
"sync.desc": "Sync encrypted backup of all books to WebDAV or a local folder",
|
||||||
|
"sync.mode": "Sync mode",
|
||||||
|
"sync.modeLocal": "Local folder",
|
||||||
|
"sync.modeWebdav": "WebDAV",
|
||||||
|
"sync.localPathPlaceholder": "Sync folder path",
|
||||||
|
"sync.pickFolder": "Pick folder",
|
||||||
|
"sync.webdavUrl": "WebDAV URL",
|
||||||
|
"sync.webdavUser": "WebDAV username",
|
||||||
|
"sync.schedule": "Schedule",
|
||||||
|
"sync.scheduleManual": "Manual",
|
||||||
|
"sync.scheduleHourly": "Hourly",
|
||||||
|
"sync.scheduleDaily": "Daily",
|
||||||
|
"sync.scheduleWeekly": "Weekly",
|
||||||
|
"sync.passwordPlaceholder": "Sync password (min 8 chars)",
|
||||||
|
"sync.passwordHint": "Set a sync password with at least 8 characters",
|
||||||
|
"sync.saveConfig": "Save sync config",
|
||||||
|
"sync.runNow": "Sync now",
|
||||||
|
"sync.running": "Syncing…",
|
||||||
|
"sync.done": "Sync complete",
|
||||||
|
"sync.configSaved": "Sync config saved",
|
||||||
|
"sync.lastSync": "Last sync: {{time}} ({{status}})",
|
||||||
|
"sync.conflictTitle": "Sync conflict",
|
||||||
|
"sync.conflictDesc": "Both local and remote changed. Choose which copy to keep.",
|
||||||
|
"sync.useRemote": "Use remote",
|
||||||
|
"sync.useLocal": "Use local",
|
||||||
|
"extensions.title": "Extensions",
|
||||||
|
"extensions.desc": "Extension API skeleton (no third-party code execution)",
|
||||||
|
"extensions.devMode": "Developer mode (manifest validation only)",
|
||||||
|
"extensions.stubHint": "Full extension marketplace coming in a future release.",
|
||||||
|
"update.available": "Update {{version}} available",
|
||||||
|
"update.download": "Download update",
|
||||||
|
"update.downloading": "Downloading {{percent}}%",
|
||||||
|
"update.install": "Restart to install",
|
||||||
|
"crash.title": "Unclean shutdown detected",
|
||||||
|
"crash.desc": "The app may have exited unexpectedly last time. You can send a local log report (stub).",
|
||||||
|
"crash.send": "Send report",
|
||||||
|
"crash.dismiss": "Dismiss",
|
||||||
|
"ai.settings.builtinSkills": "Built-in skills",
|
||||||
|
"ai.settings.skillContinue": "Continue writing",
|
||||||
|
"ai.settings.skillPolish": "Polish",
|
||||||
|
"ai.settings.skillProofread": "Proofread",
|
||||||
|
"ai.settings.mcpConnector": "MCP connector URL",
|
||||||
|
"ai.settings.mcpTest": "Test connection"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -589,5 +589,50 @@
|
|||||||
"readMode.fontSerif": "衬线",
|
"readMode.fontSerif": "衬线",
|
||||||
"readMode.fontSans": "无衬线",
|
"readMode.fontSans": "无衬线",
|
||||||
"readMode.fontEyeCare": "护眼",
|
"readMode.fontEyeCare": "护眼",
|
||||||
"readMode.tts": "朗读"
|
"readMode.tts": "朗读",
|
||||||
|
"settings.extensions": "扩展",
|
||||||
|
"sync.title": "云同步",
|
||||||
|
"sync.desc": "将加密后的全部书籍备份同步到 WebDAV 或本地云盘目录",
|
||||||
|
"sync.mode": "同步方式",
|
||||||
|
"sync.modeLocal": "本地目录",
|
||||||
|
"sync.modeWebdav": "WebDAV",
|
||||||
|
"sync.localPathPlaceholder": "选择或输入同步目录路径",
|
||||||
|
"sync.pickFolder": "选择目录",
|
||||||
|
"sync.webdavUrl": "WebDAV 地址",
|
||||||
|
"sync.webdavUser": "WebDAV 用户名",
|
||||||
|
"sync.schedule": "同步计划",
|
||||||
|
"sync.scheduleManual": "手动",
|
||||||
|
"sync.scheduleHourly": "每小时",
|
||||||
|
"sync.scheduleDaily": "每天",
|
||||||
|
"sync.scheduleWeekly": "每周",
|
||||||
|
"sync.passwordPlaceholder": "同步密码(至少 8 位)",
|
||||||
|
"sync.passwordHint": "请设置至少 8 位的同步密码",
|
||||||
|
"sync.saveConfig": "保存同步配置",
|
||||||
|
"sync.runNow": "立即同步",
|
||||||
|
"sync.running": "同步中…",
|
||||||
|
"sync.done": "同步完成",
|
||||||
|
"sync.configSaved": "同步配置已保存",
|
||||||
|
"sync.lastSync": "上次同步:{{time}}({{status}})",
|
||||||
|
"sync.conflictTitle": "同步冲突",
|
||||||
|
"sync.conflictDesc": "本地与远程均有更新,请选择保留哪一侧的数据。",
|
||||||
|
"sync.useRemote": "使用远程",
|
||||||
|
"sync.useLocal": "使用本地",
|
||||||
|
"extensions.title": "扩展",
|
||||||
|
"extensions.desc": "扩展 API 骨架(不执行第三方代码)",
|
||||||
|
"extensions.devMode": "开发者模式(仅校验 manifest)",
|
||||||
|
"extensions.stubHint": "完整插件市场将在后续版本提供。",
|
||||||
|
"update.available": "新版本 {{version}} 可用",
|
||||||
|
"update.download": "下载更新",
|
||||||
|
"update.downloading": "下载中 {{percent}}%",
|
||||||
|
"update.install": "重启并安装",
|
||||||
|
"crash.title": "检测到异常退出",
|
||||||
|
"crash.desc": "上次使用时应用可能异常退出。可发送本地日志报告(当前为 stub)。",
|
||||||
|
"crash.send": "发送报告",
|
||||||
|
"crash.dismiss": "忽略",
|
||||||
|
"ai.settings.builtinSkills": "内置 Skill",
|
||||||
|
"ai.settings.skillContinue": "续写助手",
|
||||||
|
"ai.settings.skillPolish": "润色助手",
|
||||||
|
"ai.settings.skillProofread": "纠错助手",
|
||||||
|
"ai.settings.mcpConnector": "MCP 连接器 URL",
|
||||||
|
"ai.settings.mcpTest": "测试连接"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import schemaV8 from './schema-v8.sql?raw'
|
|||||||
import schemaV9 from './schema-v9.sql?raw'
|
import schemaV9 from './schema-v9.sql?raw'
|
||||||
|
|
||||||
import schemaV10 from './schema-v10.sql?raw'
|
import schemaV10 from './schema-v10.sql?raw'
|
||||||
|
import schemaV11 from './schema-v11.sql?raw'
|
||||||
|
|
||||||
const CURRENT_VERSION = 10
|
const CURRENT_VERSION = 11
|
||||||
|
|
||||||
function tryAlter(db: SqliteDb, sql: string): void {
|
function tryAlter(db: SqliteDb, sql: string): void {
|
||||||
try {
|
try {
|
||||||
@@ -105,4 +106,9 @@ export function migrate(db: SqliteDb): void {
|
|||||||
db.exec(schemaV10)
|
db.exec(schemaV10)
|
||||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(10, 'W4 timeline')
|
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(10, 'W4 timeline')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (current < 11) {
|
||||||
|
db.exec(schemaV11)
|
||||||
|
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(11, 'W5 undo stack')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { UndoOperation, UndoStackEntry } from '../../../shared/undo'
|
||||||
|
import type { SqliteDb } from '../types'
|
||||||
|
|
||||||
|
const MAX_ENTRIES = 50
|
||||||
|
|
||||||
|
function mapRow(row: Record<string, unknown>): UndoStackEntry {
|
||||||
|
let operation: UndoOperation
|
||||||
|
try {
|
||||||
|
operation = JSON.parse(row.operation as string) as UndoOperation
|
||||||
|
} catch {
|
||||||
|
operation = { type: 'doc', content: '' }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: row.id as number,
|
||||||
|
chapterId: row.chapter_id as string,
|
||||||
|
operation,
|
||||||
|
timestamp: row.timestamp as string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UndoStackRepository {
|
||||||
|
constructor(private db: SqliteDb) {}
|
||||||
|
|
||||||
|
push(chapterId: string, operation: UndoOperation): UndoStackEntry {
|
||||||
|
const json = JSON.stringify(operation)
|
||||||
|
const result = this.db
|
||||||
|
.prepare('INSERT INTO undo_stack (chapter_id, operation) VALUES (?, ?)')
|
||||||
|
.run(chapterId, json)
|
||||||
|
const id = Number(result.lastInsertRowid)
|
||||||
|
this.trim(chapterId)
|
||||||
|
return this.get(id)!
|
||||||
|
}
|
||||||
|
|
||||||
|
list(chapterId: string, limit = MAX_ENTRIES): UndoStackEntry[] {
|
||||||
|
return (
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
'SELECT * FROM undo_stack WHERE chapter_id = ? ORDER BY id DESC LIMIT ?'
|
||||||
|
)
|
||||||
|
.all(chapterId, limit) as Record<string, unknown>[]
|
||||||
|
).map(mapRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: number): UndoStackEntry | null {
|
||||||
|
const row = this.db.prepare('SELECT * FROM undo_stack WHERE id = ?').get(id) as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined
|
||||||
|
return row ? mapRow(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
private trim(chapterId: string): void {
|
||||||
|
const rows = this.db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id FROM undo_stack WHERE chapter_id = ? ORDER BY id DESC LIMIT -1 OFFSET ?'
|
||||||
|
)
|
||||||
|
.all(chapterId, MAX_ENTRIES) as Array<{ id: number }>
|
||||||
|
for (const row of rows) {
|
||||||
|
this.db.prepare('DELETE FROM undo_stack WHERE id = ?').run(row.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS undo_stack (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
chapter_id TEXT NOT NULL,
|
||||||
|
operation TEXT NOT NULL,
|
||||||
|
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_undo_stack_chapter ON undo_stack(chapter_id, id DESC);
|
||||||
@@ -58,6 +58,8 @@ app.whenReady().then(async () => {
|
|||||||
bootstrapPackHandlers(books, settings)
|
bootstrapPackHandlers(books, settings)
|
||||||
const { bootstrapExportMatrixHandlers } = await import('./export-matrix-bootstrap.js')
|
const { bootstrapExportMatrixHandlers } = await import('./export-matrix-bootstrap.js')
|
||||||
bootstrapExportMatrixHandlers(books, settings)
|
bootstrapExportMatrixHandlers(books, settings)
|
||||||
|
const { bootstrapSyncHandlers } = await import('./sync-bootstrap.js')
|
||||||
|
bootstrapSyncHandlers(books, settings)
|
||||||
|
|
||||||
ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => {
|
ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => {
|
||||||
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC } from '../../../shared/ipc-channels'
|
||||||
|
import type { LogService } from '../../services/log.service'
|
||||||
|
import { extensionRegistry } from '../../services/extension-registry'
|
||||||
|
import { wrap } from '../result'
|
||||||
|
|
||||||
|
export function registerLogHandlers(logs: LogService): void {
|
||||||
|
ipcMain.handle(IPC.LOG_HAD_CRASH, () => wrap(() => logs.hadCrashOnLastRun()))
|
||||||
|
ipcMain.handle(IPC.LOG_CLEAR_CRASH, () =>
|
||||||
|
wrap(() => {
|
||||||
|
logs.clearCrashFlag()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
ipcMain.handle(IPC.LOG_SEND_REPORT, (_e, { note }: { note?: string }) =>
|
||||||
|
wrap(() => logs.sendReportStub(note))
|
||||||
|
)
|
||||||
|
ipcMain.handle(IPC.LOG_WRITE_ERROR, (_e, { message }: { message: string }) =>
|
||||||
|
wrap(() => {
|
||||||
|
logs.logManualError(message)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerExtensionHandlers(): void {
|
||||||
|
ipcMain.handle(IPC.EXTENSION_LIST, () => wrap(() => extensionRegistry.list()))
|
||||||
|
ipcMain.handle(IPC.EXTENSION_VALIDATE, (_e, { manifest }: { manifest: unknown }) =>
|
||||||
|
wrap(() => extensionRegistry.validateManifest(manifest))
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(IPC.MCP_TEST_CONNECTION, (_e, { url }: { url: string }) =>
|
||||||
|
wrap(() => ({ ok: true, message: `MCP stub: ${url || 'no url'}` }))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { dialog, ipcMain } from 'electron'
|
||||||
|
import { IPC } from '../../../shared/ipc-channels'
|
||||||
|
import type { SyncConfigureInput, SyncRunInput } from '../../../shared/sync'
|
||||||
|
import { SyncService } from '../../services/sync.service'
|
||||||
|
import type { BookRegistryService } from '../../services/book-registry'
|
||||||
|
import type { GlobalSettingsService } from '../../services/global-settings'
|
||||||
|
import { wrap } from '../result'
|
||||||
|
|
||||||
|
let syncService: SyncService | null = null
|
||||||
|
|
||||||
|
function svc(registry: BookRegistryService, settings: GlobalSettingsService): SyncService {
|
||||||
|
if (!syncService) {
|
||||||
|
syncService = new SyncService(registry, settings, registry.getUserDataDir())
|
||||||
|
}
|
||||||
|
return syncService
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSyncHandlers(
|
||||||
|
registry: BookRegistryService,
|
||||||
|
settings: GlobalSettingsService
|
||||||
|
): void {
|
||||||
|
const service = () => svc(registry, settings)
|
||||||
|
|
||||||
|
ipcMain.handle(IPC.SYNC_STATUS, () => wrap(() => service().getStatus()))
|
||||||
|
|
||||||
|
ipcMain.handle(IPC.SYNC_CONFIGURE, (_e, input: SyncConfigureInput) =>
|
||||||
|
wrap(() => service().configure(input))
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(IPC.SYNC_RUN, (_e, input: SyncRunInput) =>
|
||||||
|
wrap(() => service().run(input.password, input.conflictResolution))
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(IPC.SYNC_PICK_FOLDER, async () =>
|
||||||
|
wrap(async () => {
|
||||||
|
const result = await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] })
|
||||||
|
if (result.canceled || !result.filePaths[0]) return null
|
||||||
|
return result.filePaths[0]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC } from '../../../shared/ipc-channels'
|
||||||
|
import type { UndoOperation } from '../../../shared/undo'
|
||||||
|
import { UndoStackService } from '../../services/undo-stack.service'
|
||||||
|
import type { BookRegistryService } from '../../services/book-registry'
|
||||||
|
import { wrap } from '../result'
|
||||||
|
|
||||||
|
export function registerUndoHandlers(registry: BookRegistryService): void {
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.UNDO_PUSH,
|
||||||
|
(
|
||||||
|
_e,
|
||||||
|
{
|
||||||
|
bookId,
|
||||||
|
chapterId,
|
||||||
|
operation
|
||||||
|
}: { bookId: string; chapterId: string; operation: UndoOperation }
|
||||||
|
) => wrap(() => new UndoStackService(registry.getDb(bookId)).push(chapterId, operation))
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.UNDO_LIST,
|
||||||
|
(_e, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
|
||||||
|
wrap(() => new UndoStackService(registry.getDb(bookId)).list(chapterId))
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.UNDO_APPLY,
|
||||||
|
(_e, { bookId, entryId }: { bookId: string; entryId: number }) =>
|
||||||
|
wrap(() => {
|
||||||
|
const op = new UndoStackService(registry.getDb(bookId)).apply(entryId)
|
||||||
|
if (!op) throw new Error('Undo entry not found')
|
||||||
|
return op
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC } from '../../../shared/ipc-channels'
|
||||||
|
import { UpdateService } from '../../services/update.service'
|
||||||
|
import { wrap } from '../result'
|
||||||
|
|
||||||
|
let updateService: UpdateService | null = null
|
||||||
|
|
||||||
|
function service(): UpdateService {
|
||||||
|
if (!updateService) updateService = new UpdateService()
|
||||||
|
return updateService
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerUpdateHandlers(): void {
|
||||||
|
ipcMain.handle(IPC.UPDATE_CHECK, () => wrap(() => service().check()))
|
||||||
|
ipcMain.handle(IPC.UPDATE_DOWNLOAD, () => wrap(() => service().download()))
|
||||||
|
ipcMain.handle(IPC.UPDATE_INSTALL, () =>
|
||||||
|
wrap(() => {
|
||||||
|
service().install()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -39,6 +39,10 @@ import { registerExportHandlers } from './handlers/export.handler'
|
|||||||
import { registerTimelineHandlers } from './handlers/timeline.handler'
|
import { registerTimelineHandlers } from './handlers/timeline.handler'
|
||||||
import { registerArcHandlers } from './handlers/arc.handler'
|
import { registerArcHandlers } from './handlers/arc.handler'
|
||||||
import { registerComplianceHandlers } from './handlers/compliance.handler'
|
import { registerComplianceHandlers } from './handlers/compliance.handler'
|
||||||
|
import { registerUndoHandlers } from './handlers/undo.handler'
|
||||||
|
import { registerLogHandlers, registerExtensionHandlers } from './handlers/log.handler'
|
||||||
|
import { registerUpdateHandlers } from './handlers/update.handler'
|
||||||
|
import { LogService } from '../services/log.service'
|
||||||
import { AiClientService } from '../services/ai-client.service'
|
import { AiClientService } from '../services/ai-client.service'
|
||||||
import { NetworkMonitorService } from '../services/network-monitor.service'
|
import { NetworkMonitorService } from '../services/network-monitor.service'
|
||||||
|
|
||||||
@@ -46,10 +50,21 @@ let shortcutManager: ShortcutManager | null = null
|
|||||||
let snapshotService: SnapshotService | null = null
|
let snapshotService: SnapshotService | null = null
|
||||||
let networkMonitor: NetworkMonitorService | null = null
|
let networkMonitor: NetworkMonitorService | null = null
|
||||||
|
|
||||||
export function registerIpc(): { settings: GlobalSettingsService; books: BookRegistryService; shortcuts: ShortcutManager } {
|
export function registerIpc(): {
|
||||||
|
settings: GlobalSettingsService
|
||||||
|
books: BookRegistryService
|
||||||
|
shortcuts: ShortcutManager
|
||||||
|
logs: LogService
|
||||||
|
} {
|
||||||
const userData = app.getPath('userData')
|
const userData = app.getPath('userData')
|
||||||
|
|
||||||
const settings = new GlobalSettingsService(userData)
|
const settings = new GlobalSettingsService(userData)
|
||||||
|
const logs = new LogService(userData)
|
||||||
|
if (!settings.get().onboardingCompleted) {
|
||||||
|
const locale = app.getLocale().toLowerCase()
|
||||||
|
const language = locale.startsWith('en') ? 'en' : 'zh-CN'
|
||||||
|
settings.update({ language })
|
||||||
|
}
|
||||||
const books = new BookRegistryService(userData)
|
const books = new BookRegistryService(userData)
|
||||||
const writingLogRepo = new WritingLogRepository(userData)
|
const writingLogRepo = new WritingLogRepository(userData)
|
||||||
const writingLogs = new WritingLogService(writingLogRepo, settings)
|
const writingLogs = new WritingLogService(writingLogRepo, settings)
|
||||||
@@ -99,8 +114,12 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
|
|||||||
registerTimelineHandlers(books)
|
registerTimelineHandlers(books)
|
||||||
registerArcHandlers(books)
|
registerArcHandlers(books)
|
||||||
registerComplianceHandlers(settings)
|
registerComplianceHandlers(settings)
|
||||||
|
registerUndoHandlers(books)
|
||||||
|
registerLogHandlers(logs)
|
||||||
|
registerExtensionHandlers()
|
||||||
|
registerUpdateHandlers()
|
||||||
|
|
||||||
return { settings, books, shortcuts: shortcutManager }
|
return { settings, books, shortcuts: shortcutManager, logs }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getShortcutManager(): ShortcutManager | null {
|
export function getShortcutManager(): ShortcutManager | null {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { BilinExtension, ExtensionManifestEntry } from '../../shared/extension-api'
|
||||||
|
|
||||||
|
export class ExtensionRegistry {
|
||||||
|
private extensions = new Map<string, ExtensionManifestEntry>()
|
||||||
|
|
||||||
|
register(manifest: BilinExtension, sourcePath?: string): ExtensionManifestEntry {
|
||||||
|
const errors: string[] = []
|
||||||
|
if (!manifest.name?.trim()) errors.push('name required')
|
||||||
|
if (!manifest.version?.trim()) errors.push('version required')
|
||||||
|
if (!manifest.main?.trim()) errors.push('main required')
|
||||||
|
const entry: ExtensionManifestEntry = {
|
||||||
|
id: manifest.name,
|
||||||
|
manifest,
|
||||||
|
sourcePath,
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
this.extensions.set(entry.id, entry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): ExtensionManifestEntry[] {
|
||||||
|
return [...this.extensions.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
validateManifest(raw: unknown): ExtensionManifestEntry {
|
||||||
|
const manifest = raw as BilinExtension
|
||||||
|
return this.register(manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerImporter(_id: string, _extensions: string[]): void {
|
||||||
|
/* stub */
|
||||||
|
}
|
||||||
|
|
||||||
|
registerExporter(_id: string, _extensions: string[]): void {
|
||||||
|
/* stub */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extensionRegistry = new ExtensionRegistry()
|
||||||
@@ -41,7 +41,10 @@ function defaults(): GlobalSettings {
|
|||||||
defaultSubmissionPresetId: 'builtin-webnovel',
|
defaultSubmissionPresetId: 'builtin-webnovel',
|
||||||
enableComplianceCheck: false,
|
enableComplianceCheck: false,
|
||||||
complianceWords: [],
|
complianceWords: [],
|
||||||
customSlashCommands: []
|
customSlashCommands: [],
|
||||||
|
syncConfig: null,
|
||||||
|
extensionDevMode: false,
|
||||||
|
mcpConnectorUrl: ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +79,10 @@ export class GlobalSettingsService {
|
|||||||
enableComplianceCheck: raw.enableComplianceCheck ?? false,
|
enableComplianceCheck: raw.enableComplianceCheck ?? false,
|
||||||
complianceWords: raw.complianceWords ?? [],
|
complianceWords: raw.complianceWords ?? [],
|
||||||
customSlashCommands: raw.customSlashCommands ?? [],
|
customSlashCommands: raw.customSlashCommands ?? [],
|
||||||
|
syncConfig: raw.syncConfig ?? null,
|
||||||
|
syncPasswordVerifier: raw.syncPasswordVerifier,
|
||||||
|
extensionDevMode: raw.extensionDevMode ?? false,
|
||||||
|
mcpConnectorUrl: raw.mcpConnectorUrl ?? '',
|
||||||
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
|
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
|
||||||
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig }
|
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import log from 'electron-log'
|
||||||
|
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
const CRASH_FLAG = 'crash.pending'
|
||||||
|
const LOG_RETENTION_DAYS = 7
|
||||||
|
|
||||||
|
export class LogService {
|
||||||
|
private logDir: string
|
||||||
|
private logFile: string
|
||||||
|
private crashFlagPath: string
|
||||||
|
|
||||||
|
constructor(userDataDir: string) {
|
||||||
|
this.logDir = join(userDataDir, 'logs')
|
||||||
|
this.logFile = join(this.logDir, 'bilin.log')
|
||||||
|
this.crashFlagPath = join(userDataDir, CRASH_FLAG)
|
||||||
|
if (!existsSync(this.logDir)) mkdirSync(this.logDir, { recursive: true })
|
||||||
|
|
||||||
|
log.transports.file.resolvePathFn = () => this.logFile
|
||||||
|
log.transports.file.maxSize = 5 * 1024 * 1024
|
||||||
|
log.transports.console.level = process.env.BILIN_E2E === '1' ? 'error' : 'info'
|
||||||
|
|
||||||
|
this.pruneOldLogs()
|
||||||
|
this.installGlobalHandlers()
|
||||||
|
}
|
||||||
|
|
||||||
|
getLogger(): typeof log {
|
||||||
|
return log
|
||||||
|
}
|
||||||
|
|
||||||
|
hadCrashOnLastRun(): boolean {
|
||||||
|
return existsSync(this.crashFlagPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCrashFlag(): void {
|
||||||
|
if (existsSync(this.crashFlagPath)) rmSync(this.crashFlagPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
markUncleanExit(): void {
|
||||||
|
writeFileSync(this.crashFlagPath, new Date().toISOString(), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
sendReportStub(note?: string): { ok: true; loggedAt: string } {
|
||||||
|
const msg = note ? `User report: ${note}` : 'User report submitted (stub)'
|
||||||
|
log.error(msg)
|
||||||
|
this.clearCrashFlag()
|
||||||
|
return { ok: true, loggedAt: new Date().toISOString() }
|
||||||
|
}
|
||||||
|
|
||||||
|
logManualError(message: string, detail?: unknown): void {
|
||||||
|
log.error(message, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
private installGlobalHandlers(): void {
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
log.error('uncaughtException', err)
|
||||||
|
this.markUncleanExit()
|
||||||
|
})
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
log.error('unhandledRejection', reason)
|
||||||
|
})
|
||||||
|
app?.on?.('before-quit', () => {
|
||||||
|
this.clearCrashFlag()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneOldLogs(): void {
|
||||||
|
if (!existsSync(this.logDir)) return
|
||||||
|
const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000
|
||||||
|
for (const name of readdirSync(this.logDir)) {
|
||||||
|
const full = join(this.logDir, name)
|
||||||
|
try {
|
||||||
|
if (statSync(full).mtimeMs < cutoff) rmSync(full, { force: true })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, createHash, pbkdf2Sync, randomBytes } from 'crypto'
|
||||||
|
import { readFileSync, writeFileSync } from 'fs'
|
||||||
|
|
||||||
|
const ALGO = 'aes-256-gcm'
|
||||||
|
const KEY_LEN = 32
|
||||||
|
const IV_LEN = 12
|
||||||
|
const TAG_LEN = 16
|
||||||
|
const PBKDF2_ITERATIONS = 120_000
|
||||||
|
|
||||||
|
export function hashSyncPassword(password: string, salt: string): string {
|
||||||
|
return createHash('sha256').update(`${salt}:${password}`).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveKey(password: string, salt: Buffer): Buffer {
|
||||||
|
return pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LEN, 'sha256')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSalt(): string {
|
||||||
|
return randomBytes(16).toString('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptFile(inputPath: string, outputPath: string, password: string, saltHex: string): void {
|
||||||
|
const salt = Buffer.from(saltHex, 'hex')
|
||||||
|
const key = deriveKey(password, salt)
|
||||||
|
const iv = randomBytes(IV_LEN)
|
||||||
|
const plain = readFileSync(inputPath)
|
||||||
|
const cipher = createCipheriv(ALGO, key, iv)
|
||||||
|
const encrypted = Buffer.concat([cipher.update(plain), cipher.final()])
|
||||||
|
const tag = cipher.getAuthTag()
|
||||||
|
const out = Buffer.concat([salt, iv, tag, encrypted])
|
||||||
|
writeFileSync(outputPath, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptFile(inputPath: string, outputPath: string, password: string): void {
|
||||||
|
const buf = readFileSync(inputPath)
|
||||||
|
const salt = buf.subarray(0, 16)
|
||||||
|
const iv = buf.subarray(16, 16 + IV_LEN)
|
||||||
|
const tag = buf.subarray(16 + IV_LEN, 16 + IV_LEN + TAG_LEN)
|
||||||
|
const data = buf.subarray(16 + IV_LEN + TAG_LEN)
|
||||||
|
const key = deriveKey(password, salt)
|
||||||
|
const decipher = createDecipheriv(ALGO, key, iv)
|
||||||
|
decipher.setAuthTag(tag)
|
||||||
|
const plain = Buffer.concat([decipher.update(data), decipher.final()])
|
||||||
|
writeFileSync(outputPath, plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SYNC_BUNDLE_NAME = 'bilin-sync.novel-all.enc'
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { app, BrowserWindow } from 'electron'
|
||||||
|
import {
|
||||||
|
copyFileSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync
|
||||||
|
} from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { IPC } from '../../shared/ipc-channels'
|
||||||
|
import type { SyncConfig, SyncProgressEvent, SyncRunResult } from '../../shared/sync'
|
||||||
|
import type { BookRegistryService } from './book-registry'
|
||||||
|
import type { GlobalSettingsService } from './global-settings'
|
||||||
|
import { PackService } from './pack.service'
|
||||||
|
import { decryptFile, encryptFile, generateSalt, hashSyncPassword, SYNC_BUNDLE_NAME } from './sync-crypto'
|
||||||
|
|
||||||
|
function broadcastProgress(event: SyncProgressEvent): void {
|
||||||
|
try {
|
||||||
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
|
win.webContents.send(IPC.SYNC_PROGRESS, event)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* test env */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SyncService {
|
||||||
|
private scheduleTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
private sessionPassword: string | null = null
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private registry: BookRegistryService,
|
||||||
|
private settings: GlobalSettingsService,
|
||||||
|
private userDataDir: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getStatus(): SyncConfig | null {
|
||||||
|
return this.settings.get().syncConfig ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
configure(input: { config: SyncConfig; password: string }): SyncConfig {
|
||||||
|
if (input.password.length < 8) {
|
||||||
|
throw new Error('同步密码至少 8 位')
|
||||||
|
}
|
||||||
|
const salt = input.config.syncSalt ?? generateSalt()
|
||||||
|
const next: SyncConfig = {
|
||||||
|
...input.config,
|
||||||
|
syncSalt: salt,
|
||||||
|
passwordConfigured: true,
|
||||||
|
lastSyncStatus: 'idle'
|
||||||
|
}
|
||||||
|
this.sessionPassword = input.password
|
||||||
|
this.settings.update({
|
||||||
|
syncConfig: next,
|
||||||
|
syncPasswordVerifier: hashSyncPassword(input.password, salt)
|
||||||
|
})
|
||||||
|
this.applySchedule()
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionPassword(password: string): void {
|
||||||
|
const cfg = this.settings.get().syncConfig
|
||||||
|
const salt = cfg?.syncSalt
|
||||||
|
const verifier = this.settings.get().syncPasswordVerifier
|
||||||
|
if (!salt || !verifier) throw new Error('尚未配置同步')
|
||||||
|
if (hashSyncPassword(password, salt) !== verifier) {
|
||||||
|
throw new Error('同步密码错误')
|
||||||
|
}
|
||||||
|
this.sessionPassword = password
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(
|
||||||
|
password: string,
|
||||||
|
conflictResolution?: 'local' | 'remote' | 'cancel'
|
||||||
|
): Promise<SyncRunResult> {
|
||||||
|
this.setSessionPassword(password)
|
||||||
|
const cfg = this.settings.get().syncConfig
|
||||||
|
if (!cfg?.passwordConfigured || !cfg.syncSalt) {
|
||||||
|
throw new Error('请先完成同步配置')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'bilin-sync-'))
|
||||||
|
const plainPath = join(tempDir, 'bundle.novel-all')
|
||||||
|
const encPath = join(tempDir, SYNC_BUNDLE_NAME)
|
||||||
|
|
||||||
|
try {
|
||||||
|
broadcastProgress({ phase: 'export', message: '正在打包书籍…', percent: 10 })
|
||||||
|
const pack = new PackService(this.registry, this.settings, this.userDataDir)
|
||||||
|
await pack.exportAll(plainPath, (p) =>
|
||||||
|
broadcastProgress({
|
||||||
|
phase: 'export',
|
||||||
|
message: p.message,
|
||||||
|
percent: 10 + Math.round((p.current / Math.max(p.total, 1)) * 30)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const remotePath = this.remoteBundlePath(cfg)
|
||||||
|
const remoteExists = await this.remoteExists(cfg, remotePath)
|
||||||
|
const remoteMtime = await this.remoteMtime(cfg, remotePath)
|
||||||
|
const localEditedAt = Date.now()
|
||||||
|
const lastSyncAt = cfg.lastSyncAt ? Date.parse(cfg.lastSyncAt) : 0
|
||||||
|
const remoteNewer = remoteExists && remoteMtime > lastSyncAt
|
||||||
|
const localNewer = localEditedAt > lastSyncAt
|
||||||
|
|
||||||
|
if (remoteExists && remoteNewer && localNewer && !conflictResolution) {
|
||||||
|
this.patchStatus('conflict')
|
||||||
|
return {
|
||||||
|
status: 'conflict',
|
||||||
|
syncedAt: new Date().toISOString(),
|
||||||
|
remoteNewer: true,
|
||||||
|
localNewer: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conflictResolution === 'cancel') {
|
||||||
|
return { status: 'idle', syncedAt: new Date().toISOString() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conflictResolution === 'remote' && remoteExists) {
|
||||||
|
broadcastProgress({ phase: 'download', message: '正在下载远程备份…', percent: 60 })
|
||||||
|
const downloaded = join(tempDir, 'remote.enc')
|
||||||
|
await this.fetchRemote(cfg, remotePath, downloaded)
|
||||||
|
broadcastProgress({ phase: 'decrypt', message: '正在解密…', percent: 75 })
|
||||||
|
const decrypted = join(tempDir, 'restored.novel-all')
|
||||||
|
decryptFile(downloaded, decrypted, password)
|
||||||
|
broadcastProgress({ phase: 'import', message: '正在导入…', percent: 90 })
|
||||||
|
await pack.importAll(decrypted, 'merge')
|
||||||
|
} else {
|
||||||
|
broadcastProgress({ phase: 'encrypt', message: '正在加密…', percent: 50 })
|
||||||
|
encryptFile(plainPath, encPath, password, cfg.syncSalt)
|
||||||
|
broadcastProgress({ phase: 'upload', message: '正在同步…', percent: 70 })
|
||||||
|
await this.pushRemote(cfg, remotePath, encPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncedAt = new Date().toISOString()
|
||||||
|
this.patchStatus('ok', syncedAt)
|
||||||
|
broadcastProgress({ phase: 'done', message: '同步完成', percent: 100 })
|
||||||
|
return { status: 'ok', syncedAt }
|
||||||
|
} catch (err) {
|
||||||
|
this.patchStatus('error')
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applySchedule(): void {
|
||||||
|
if (this.scheduleTimer) clearInterval(this.scheduleTimer)
|
||||||
|
const cfg = this.settings.get().syncConfig
|
||||||
|
if (!cfg || cfg.schedule === 'manual' || !this.sessionPassword) return
|
||||||
|
const ms =
|
||||||
|
cfg.schedule === 'hourly'
|
||||||
|
? 60 * 60 * 1000
|
||||||
|
: cfg.schedule === 'daily'
|
||||||
|
? 24 * 60 * 60 * 1000
|
||||||
|
: 7 * 24 * 60 * 60 * 1000
|
||||||
|
this.scheduleTimer = setInterval(() => {
|
||||||
|
if (!this.sessionPassword) return
|
||||||
|
void this.run(this.sessionPassword).catch(() => this.patchStatus('error'))
|
||||||
|
}, ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
private patchStatus(status: SyncConfig['lastSyncStatus'], lastSyncAt?: string): void {
|
||||||
|
const current = this.settings.get().syncConfig
|
||||||
|
if (!current) return
|
||||||
|
this.settings.update({
|
||||||
|
syncConfig: {
|
||||||
|
...current,
|
||||||
|
lastSyncStatus: status,
|
||||||
|
lastSyncAt: lastSyncAt ?? current.lastSyncAt ?? null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private remoteBundlePath(cfg: SyncConfig): string {
|
||||||
|
if (cfg.mode === 'local-folder') {
|
||||||
|
if (!cfg.localFolderPath) throw new Error('未配置本地同步目录')
|
||||||
|
return join(cfg.localFolderPath, SYNC_BUNDLE_NAME)
|
||||||
|
}
|
||||||
|
if (!cfg.webdavUrl) throw new Error('未配置 WebDAV 地址')
|
||||||
|
const base = cfg.webdavUrl.replace(/\/$/, '')
|
||||||
|
return `${base}/${SYNC_BUNDLE_NAME}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private async remoteExists(cfg: SyncConfig, remotePath: string): Promise<boolean> {
|
||||||
|
if (cfg.mode === 'local-folder') return existsSync(remotePath)
|
||||||
|
try {
|
||||||
|
const res = await fetch(remotePath, { method: 'HEAD' })
|
||||||
|
return res.ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async remoteMtime(cfg: SyncConfig, remotePath: string): Promise<number> {
|
||||||
|
if (cfg.mode === 'local-folder') {
|
||||||
|
return existsSync(remotePath) ? statSync(remotePath).mtimeMs : 0
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(remotePath, { method: 'HEAD' })
|
||||||
|
const lm = res.headers.get('last-modified')
|
||||||
|
return lm ? Date.parse(lm) : 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pushRemote(cfg: SyncConfig, remotePath: string, localEncPath: string): Promise<void> {
|
||||||
|
if (cfg.mode === 'local-folder') {
|
||||||
|
mkdirSync(cfg.localFolderPath!, { recursive: true })
|
||||||
|
copyFileSync(localEncPath, remotePath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const body = readFileSync(localEncPath)
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/octet-stream' }
|
||||||
|
if (cfg.webdavUser && this.sessionPassword) {
|
||||||
|
const token = Buffer.from(`${cfg.webdavUser}:${this.sessionPassword}`).toString('base64')
|
||||||
|
headers.Authorization = `Basic ${token}`
|
||||||
|
}
|
||||||
|
const res = await fetch(remotePath, { method: 'PUT', headers, body })
|
||||||
|
if (!res.ok) throw new Error(`WebDAV 上传失败: ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchRemote(cfg: SyncConfig, remotePath: string, destPath: string): Promise<void> {
|
||||||
|
if (cfg.mode === 'local-folder') {
|
||||||
|
copyFileSync(remotePath, destPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (cfg.webdavUser && this.sessionPassword) {
|
||||||
|
const token = Buffer.from(`${cfg.webdavUser}:${this.sessionPassword}`).toString('base64')
|
||||||
|
headers.Authorization = `Basic ${token}`
|
||||||
|
}
|
||||||
|
const res = await fetch(remotePath, { headers })
|
||||||
|
if (!res.ok) throw new Error(`WebDAV 下载失败: ${res.status}`)
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer())
|
||||||
|
writeFileSync(destPath, buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { UndoOperation, UndoStackEntry } from '../../shared/undo'
|
||||||
|
import { UndoStackRepository } from '../db/repositories/undo.repo'
|
||||||
|
import type { SqliteDb } from '../db/types'
|
||||||
|
|
||||||
|
export class UndoStackService {
|
||||||
|
constructor(private db: SqliteDb) {}
|
||||||
|
|
||||||
|
push(chapterId: string, operation: UndoOperation): UndoStackEntry {
|
||||||
|
return new UndoStackRepository(this.db).push(chapterId, operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
list(chapterId: string): UndoStackEntry[] {
|
||||||
|
return new UndoStackRepository(this.db).list(chapterId)
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(entryId: number): UndoOperation | null {
|
||||||
|
const entry = new UndoStackRepository(this.db).get(entryId)
|
||||||
|
return entry?.operation ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { app, BrowserWindow } from 'electron'
|
||||||
|
import electronUpdater from 'electron-updater'
|
||||||
|
import { IPC } from '../../shared/ipc-channels'
|
||||||
|
|
||||||
|
const { autoUpdater } = electronUpdater
|
||||||
|
|
||||||
|
export interface UpdateInfoPayload {
|
||||||
|
version: string
|
||||||
|
releaseNotes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProgressPayload {
|
||||||
|
percent: number
|
||||||
|
transferred: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateService {
|
||||||
|
private disabled: boolean
|
||||||
|
private mockVersion: string | null = null
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (process.env.BILIN_MOCK_UPDATE === '1') {
|
||||||
|
this.disabled = false
|
||||||
|
this.mockVersion = process.env.BILIN_MOCK_UPDATE_VERSION ?? '9.9.9'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.disabled =
|
||||||
|
process.env.BILIN_E2E === '1' ||
|
||||||
|
!app.isPackaged ||
|
||||||
|
process.env.NODE_ENV === 'development'
|
||||||
|
|
||||||
|
autoUpdater.autoDownload = false
|
||||||
|
autoUpdater.autoInstallOnAppQuit = true
|
||||||
|
|
||||||
|
autoUpdater.on('update-available', (info) => {
|
||||||
|
this.broadcast(IPC.UPDATE_AVAILABLE, {
|
||||||
|
version: info.version,
|
||||||
|
releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined
|
||||||
|
})
|
||||||
|
})
|
||||||
|
autoUpdater.on('download-progress', (p) => {
|
||||||
|
this.broadcast(IPC.UPDATE_PROGRESS, {
|
||||||
|
percent: p.percent,
|
||||||
|
transferred: p.transferred,
|
||||||
|
total: p.total
|
||||||
|
})
|
||||||
|
})
|
||||||
|
autoUpdater.on('update-downloaded', (info) => {
|
||||||
|
this.broadcast(IPC.UPDATE_DOWNLOADED, { version: info.version })
|
||||||
|
})
|
||||||
|
autoUpdater.on('error', (err) => {
|
||||||
|
this.broadcast(IPC.UPDATE_ERROR, { message: err.message })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async check(): Promise<UpdateInfoPayload | null> {
|
||||||
|
if (this.disabled) return null
|
||||||
|
if (this.mockVersion) {
|
||||||
|
const payload = { version: this.mockVersion, releaseNotes: 'Mock update' }
|
||||||
|
this.broadcast(IPC.UPDATE_AVAILABLE, payload)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
const result = await autoUpdater.checkForUpdates()
|
||||||
|
const info = result?.updateInfo
|
||||||
|
if (!info) return null
|
||||||
|
return {
|
||||||
|
version: info.version,
|
||||||
|
releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async download(): Promise<void> {
|
||||||
|
if (this.disabled) return
|
||||||
|
if (this.mockVersion) {
|
||||||
|
this.broadcast(IPC.UPDATE_PROGRESS, { percent: 50, transferred: 50, total: 100 })
|
||||||
|
this.broadcast(IPC.UPDATE_DOWNLOADED, { version: this.mockVersion })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await autoUpdater.downloadUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
install(): void {
|
||||||
|
if (this.disabled || this.mockVersion) return
|
||||||
|
autoUpdater.quitAndInstall()
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcast(channel: string, payload: unknown): void {
|
||||||
|
try {
|
||||||
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
|
win.webContents.send(channel, payload)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* test env */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { registerSyncHandlers } from './ipc/handlers/sync.handler'
|
||||||
|
import type { BookRegistryService } from './services/book-registry'
|
||||||
|
import type { GlobalSettingsService } from './services/global-settings'
|
||||||
|
|
||||||
|
export function bootstrapSyncHandlers(
|
||||||
|
registry: BookRegistryService,
|
||||||
|
settings: GlobalSettingsService
|
||||||
|
): void {
|
||||||
|
registerSyncHandlers(registry, settings)
|
||||||
|
}
|
||||||
@@ -69,6 +69,15 @@ import type { ExportMatrixParams } from '../shared/export-matrix'
|
|||||||
import type { TimelineEntry, TimelineConflict, TimelineUpsertManualInput } from '../shared/timeline'
|
import type { TimelineEntry, TimelineConflict, TimelineUpsertManualInput } from '../shared/timeline'
|
||||||
import type { CharacterArcNode } from '../shared/arc'
|
import type { CharacterArcNode } from '../shared/arc'
|
||||||
import type { ComplianceScanResult } from '../shared/compliance'
|
import type { ComplianceScanResult } from '../shared/compliance'
|
||||||
|
import type { UndoOperation, UndoStackEntry } from '../shared/undo'
|
||||||
|
import type {
|
||||||
|
SyncConfig,
|
||||||
|
SyncConfigureInput,
|
||||||
|
SyncProgressEvent,
|
||||||
|
SyncRunInput,
|
||||||
|
SyncRunResult
|
||||||
|
} from '../shared/sync'
|
||||||
|
import type { ExtensionManifestEntry } from '../shared/extension-api'
|
||||||
|
|
||||||
const electronAPI = {
|
const electronAPI = {
|
||||||
window: {
|
window: {
|
||||||
@@ -635,6 +644,49 @@ const electronAPI = {
|
|||||||
checkText: (text: string): Promise<IpcResult<ComplianceScanResult>> =>
|
checkText: (text: string): Promise<IpcResult<ComplianceScanResult>> =>
|
||||||
ipcRenderer.invoke(IPC.COMPLIANCE_CHECK_TEXT, { text })
|
ipcRenderer.invoke(IPC.COMPLIANCE_CHECK_TEXT, { text })
|
||||||
},
|
},
|
||||||
|
undo: {
|
||||||
|
push: (
|
||||||
|
bookId: string,
|
||||||
|
chapterId: string,
|
||||||
|
operation: UndoOperation
|
||||||
|
): Promise<IpcResult<UndoStackEntry>> =>
|
||||||
|
ipcRenderer.invoke(IPC.UNDO_PUSH, { bookId, chapterId, operation }),
|
||||||
|
list: (bookId: string, chapterId: string): Promise<IpcResult<UndoStackEntry[]>> =>
|
||||||
|
ipcRenderer.invoke(IPC.UNDO_LIST, { bookId, chapterId }),
|
||||||
|
apply: (bookId: string, entryId: number): Promise<IpcResult<UndoOperation>> =>
|
||||||
|
ipcRenderer.invoke(IPC.UNDO_APPLY, { bookId, entryId })
|
||||||
|
},
|
||||||
|
sync: {
|
||||||
|
status: (): Promise<IpcResult<SyncConfig | null>> => ipcRenderer.invoke(IPC.SYNC_STATUS),
|
||||||
|
configure: (input: SyncConfigureInput): Promise<IpcResult<SyncConfig>> =>
|
||||||
|
ipcRenderer.invoke(IPC.SYNC_CONFIGURE, input),
|
||||||
|
run: (input: SyncRunInput): Promise<IpcResult<SyncRunResult>> =>
|
||||||
|
ipcRenderer.invoke(IPC.SYNC_RUN, input),
|
||||||
|
pickFolder: (): Promise<IpcResult<string | null>> => ipcRenderer.invoke(IPC.SYNC_PICK_FOLDER)
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
check: (): Promise<IpcResult<{ version: string; releaseNotes?: string } | null>> =>
|
||||||
|
ipcRenderer.invoke(IPC.UPDATE_CHECK),
|
||||||
|
download: (): Promise<IpcResult<void>> => ipcRenderer.invoke(IPC.UPDATE_DOWNLOAD),
|
||||||
|
install: (): Promise<IpcResult<void>> => ipcRenderer.invoke(IPC.UPDATE_INSTALL)
|
||||||
|
},
|
||||||
|
log: {
|
||||||
|
hadCrash: (): Promise<IpcResult<boolean>> => ipcRenderer.invoke(IPC.LOG_HAD_CRASH),
|
||||||
|
clearCrash: (): Promise<IpcResult<void>> => ipcRenderer.invoke(IPC.LOG_CLEAR_CRASH),
|
||||||
|
sendReport: (note?: string): Promise<IpcResult<{ ok: true; loggedAt: string }>> =>
|
||||||
|
ipcRenderer.invoke(IPC.LOG_SEND_REPORT, { note }),
|
||||||
|
writeError: (message: string): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.LOG_WRITE_ERROR, { message })
|
||||||
|
},
|
||||||
|
extension: {
|
||||||
|
list: (): Promise<IpcResult<ExtensionManifestEntry[]>> => ipcRenderer.invoke(IPC.EXTENSION_LIST),
|
||||||
|
validate: (manifest: unknown): Promise<IpcResult<ExtensionManifestEntry>> =>
|
||||||
|
ipcRenderer.invoke(IPC.EXTENSION_VALIDATE, { manifest })
|
||||||
|
},
|
||||||
|
mcp: {
|
||||||
|
testConnection: (url: string): Promise<IpcResult<{ ok: boolean; message: string }>> =>
|
||||||
|
ipcRenderer.invoke(IPC.MCP_TEST_CONNECTION, { url })
|
||||||
|
},
|
||||||
pack: {
|
pack: {
|
||||||
pickFile: (
|
pickFile: (
|
||||||
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
|
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
|
||||||
@@ -736,6 +788,44 @@ const electronAPI = {
|
|||||||
}
|
}
|
||||||
ipcRenderer.on(IPC.PACK_PROGRESS, handler)
|
ipcRenderer.on(IPC.PACK_PROGRESS, handler)
|
||||||
return () => ipcRenderer.removeListener(IPC.PACK_PROGRESS, handler)
|
return () => ipcRenderer.removeListener(IPC.PACK_PROGRESS, handler)
|
||||||
|
},
|
||||||
|
onSyncProgress: (callback: (payload: SyncProgressEvent) => void): (() => void) => {
|
||||||
|
const handler = (_event: IpcRendererEvent, payload: SyncProgressEvent) => {
|
||||||
|
callback(payload)
|
||||||
|
}
|
||||||
|
ipcRenderer.on(IPC.SYNC_PROGRESS, handler)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.SYNC_PROGRESS, handler)
|
||||||
|
},
|
||||||
|
onUpdateAvailable: (
|
||||||
|
callback: (payload: { version: string; releaseNotes?: string }) => void
|
||||||
|
): (() => void) => {
|
||||||
|
const handler = (
|
||||||
|
_event: IpcRendererEvent,
|
||||||
|
payload: { version: string; releaseNotes?: string }
|
||||||
|
) => {
|
||||||
|
callback(payload)
|
||||||
|
}
|
||||||
|
ipcRenderer.on(IPC.UPDATE_AVAILABLE, handler)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.UPDATE_AVAILABLE, handler)
|
||||||
|
},
|
||||||
|
onUpdateProgress: (
|
||||||
|
callback: (payload: { percent: number; transferred: number; total: number }) => void
|
||||||
|
): (() => void) => {
|
||||||
|
const handler = (
|
||||||
|
_event: IpcRendererEvent,
|
||||||
|
payload: { percent: number; transferred: number; total: number }
|
||||||
|
) => {
|
||||||
|
callback(payload)
|
||||||
|
}
|
||||||
|
ipcRenderer.on(IPC.UPDATE_PROGRESS, handler)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.UPDATE_PROGRESS, handler)
|
||||||
|
},
|
||||||
|
onUpdateDownloaded: (callback: (payload: { version: string }) => void): (() => void) => {
|
||||||
|
const handler = (_event: IpcRendererEvent, payload: { version: string }) => {
|
||||||
|
callback(payload)
|
||||||
|
}
|
||||||
|
ipcRenderer.on(IPC.UPDATE_DOWNLOADED, handler)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.UPDATE_DOWNLOADED, handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import './styles/themes.css'
|
|||||||
import './styles/globals.css'
|
import './styles/globals.css'
|
||||||
import './styles/layout.css'
|
import './styles/layout.css'
|
||||||
import { TopBar } from '@renderer/components/layout/TopBar'
|
import { TopBar } from '@renderer/components/layout/TopBar'
|
||||||
|
import { UpdateBanner } from '@renderer/components/settings/UpdateBanner'
|
||||||
import { HomePage } from '@renderer/components/home/HomePage'
|
import { HomePage } from '@renderer/components/home/HomePage'
|
||||||
import { EditorLayout } from '@renderer/components/layout/EditorLayout'
|
import { EditorLayout } from '@renderer/components/layout/EditorLayout'
|
||||||
import { SettingsPage } from '@renderer/components/settings/SettingsPage'
|
import { SettingsPage } from '@renderer/components/settings/SettingsPage'
|
||||||
@@ -31,6 +32,7 @@ import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
|
|||||||
import { isSpeaking, speakText, stopSpeaking } from '@renderer/lib/tts-controller'
|
import { isSpeaking, speakText, stopSpeaking } from '@renderer/lib/tts-controller'
|
||||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||||
import { useExportStore } from '@renderer/stores/useExportStore'
|
import { useExportStore } from '@renderer/stores/useExportStore'
|
||||||
|
import { CrashReportDialog } from '@renderer/components/settings/CrashReportDialog'
|
||||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
function AppInner(): React.JSX.Element {
|
function AppInner(): React.JSX.Element {
|
||||||
@@ -48,6 +50,13 @@ function AppInner(): React.JSX.Element {
|
|||||||
const { activeTabId, openTabs } = useTabStore()
|
const { activeTabId, openTabs } = useTabStore()
|
||||||
const openBook = useBookStore((s) => s.openBook)
|
const openBook = useBookStore((s) => s.openBook)
|
||||||
const [showOnboarding, setShowOnboarding] = useState(false)
|
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||||
|
const [crashDialogOpen, setCrashDialogOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void ipcCall(() => window.electronAPI.log.hadCrash()).then((had) => {
|
||||||
|
if (had) setCrashDialogOpen(true)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -202,6 +211,7 @@ function AppInner(): React.JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<div id="app" className={focusMode ? 'focus-mode' : undefined} data-testid="app-root">
|
<div id="app" className={focusMode ? 'focus-mode' : undefined} data-testid="app-root">
|
||||||
<TopBar />
|
<TopBar />
|
||||||
|
<UpdateBanner />
|
||||||
<div id="main-area">
|
<div id="main-area">
|
||||||
{view === 'home' && <HomePage />}
|
{view === 'home' && <HomePage />}
|
||||||
{view === 'editor' && <EditorLayout />}
|
{view === 'editor' && <EditorLayout />}
|
||||||
@@ -215,6 +225,7 @@ function AppInner(): React.JSX.Element {
|
|||||||
<ImportBookModal />
|
<ImportBookModal />
|
||||||
<ExportAllModal />
|
<ExportAllModal />
|
||||||
<TimelineModal />
|
<TimelineModal />
|
||||||
|
<CrashReportDialog open={crashDialogOpen} onClose={() => setCrashDialogOpen(false)} />
|
||||||
<NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} />
|
<NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} />
|
||||||
{toast && <div className="toast">{toast}</div>}
|
{toast && <div className="toast">{toast}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -87,6 +87,13 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
|
|||||||
const updateInspirationLocal = useBookStore((s) => s.updateInspirationLocal)
|
const updateInspirationLocal = useBookStore((s) => s.updateInspirationLocal)
|
||||||
const settings = useBookStore((s) => s.settings)
|
const settings = useBookStore((s) => s.settings)
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout>>()
|
const timerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||||
|
const undoTimerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||||
|
const lastPushedContentRef = useRef<string | null>(null)
|
||||||
|
const persistentUndoRef = useRef<{ entries: import('@shared/undo').UndoStackEntry[]; index: number }>({
|
||||||
|
entries: [],
|
||||||
|
index: -1
|
||||||
|
})
|
||||||
|
const editorRef = useRef<ReturnType<typeof useEditor>>(null)
|
||||||
const targetKeyRef = useRef<string | null>(null)
|
const targetKeyRef = useRef<string | null>(null)
|
||||||
const [mentionOpen, setMentionOpen] = useState(false)
|
const [mentionOpen, setMentionOpen] = useState(false)
|
||||||
const [mentionQuery, setMentionQuery] = useState('')
|
const [mentionQuery, setMentionQuery] = useState('')
|
||||||
@@ -139,6 +146,26 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
|
|||||||
}
|
}
|
||||||
const text = ed.getText()
|
const text = ed.getText()
|
||||||
const chapterCount = countPlain(text)
|
const chapterCount = countPlain(text)
|
||||||
|
if (isChapter && bookId && target?.kind === 'chapter') {
|
||||||
|
clearTimeout(undoTimerRef.current)
|
||||||
|
undoTimerRef.current = setTimeout(() => {
|
||||||
|
const html = ed.getHTML()
|
||||||
|
const prev = lastPushedContentRef.current
|
||||||
|
if (prev !== null && prev !== html) {
|
||||||
|
void ipcCall(() =>
|
||||||
|
window.electronAPI.undo.push(bookId, target.id, { type: 'doc', content: prev })
|
||||||
|
).then(() =>
|
||||||
|
ipcCall(() => window.electronAPI.undo.list(bookId, target.id)).then((entries) => {
|
||||||
|
persistentUndoRef.current = {
|
||||||
|
entries,
|
||||||
|
index: entries.length > 0 ? entries.length - 1 : -1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
lastPushedContentRef.current = html
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
if (isChapter && target?.kind === 'chapter') {
|
if (isChapter && target?.kind === 'chapter') {
|
||||||
const ch = chapters.find((c) => c.id === target.id)
|
const ch = chapters.find((c) => c.id === target.id)
|
||||||
const volId = ch?.volumeId
|
const volId = ch?.volumeId
|
||||||
@@ -185,12 +212,26 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
|
|||||||
const pos = coords?.pos ?? view.state.selection.from
|
const pos = coords?.pos ?? view.state.selection.from
|
||||||
view.dispatch(view.state.tr.insertText(text, pos))
|
view.dispatch(view.state.tr.insertText(text, pos))
|
||||||
return true
|
return true
|
||||||
|
},
|
||||||
|
handleKeyDown: (_view, event) => {
|
||||||
|
if (!(event.ctrlKey || event.metaKey) || event.key !== 'z' || event.shiftKey) return false
|
||||||
|
const ed = editorRef.current
|
||||||
|
if (!ed || ed.can().undo()) return false
|
||||||
|
const slot = persistentUndoRef.current
|
||||||
|
const entry = slot.entries[slot.index]
|
||||||
|
if (!entry) return false
|
||||||
|
slot.index -= 1
|
||||||
|
ed.commands.setContent(entry.operation.content)
|
||||||
|
event.preventDefault()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[target?.kind, target?.id, i18n.language]
|
[target?.kind, target?.id, i18n.language, isChapter, bookId]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
editorRef.current = editor
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
const save = useCallback(async () => {
|
||||||
if (!bookId || !target || !editor) return
|
if (!bookId || !target || !editor) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -356,6 +397,15 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
|
|||||||
targetKeyRef.current = key
|
targetKeyRef.current = key
|
||||||
|
|
||||||
editor.commands.setContent(content || '')
|
editor.commands.setContent(content || '')
|
||||||
|
lastPushedContentRef.current = content || ''
|
||||||
|
if (bookId && target.kind === 'chapter') {
|
||||||
|
void ipcCall(() => window.electronAPI.undo.list(bookId, target.id)).then((entries) => {
|
||||||
|
persistentUndoRef.current = {
|
||||||
|
entries,
|
||||||
|
index: entries.length > 0 ? entries.length - 1 : -1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
if (target.kind === 'chapter') {
|
if (target.kind === 'chapter') {
|
||||||
const ch = chapters.find((c) => c.id === target.id)
|
const ch = chapters.find((c) => c.id === target.id)
|
||||||
const pos = Math.min(ch?.cursorOffset ?? 0, editor.state.doc.content.size)
|
const pos = Math.min(ch?.cursorOffset ?? 0, editor.state.doc.content.size)
|
||||||
|
|||||||
@@ -148,6 +148,38 @@ export function AiSettingsPage(): React.JSX.Element {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||||
|
<span>{t('ai.settings.builtinSkills')}</span>
|
||||||
|
<ul className="builtin-skill-list" data-testid="builtin-skills-list">
|
||||||
|
<li>{t('ai.settings.skillContinue')}</li>
|
||||||
|
<li>{t('ai.settings.skillPolish')}</li>
|
||||||
|
<li>{t('ai.settings.skillProofread')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
|
||||||
|
<span>{t('ai.settings.mcpConnector')}</span>
|
||||||
|
<input
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
data-testid="mcp-connector-url"
|
||||||
|
value={settings.mcpConnectorUrl ?? ''}
|
||||||
|
onChange={(e) => void settings.update({ mcpConnectorUrl: e.target.value })}
|
||||||
|
placeholder="https://mcp.example.com"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
data-testid="mcp-test-connection"
|
||||||
|
onClick={() =>
|
||||||
|
void ipcCall(() =>
|
||||||
|
window.electronAPI.mcp.testConnection(settings.mcpConnectorUrl ?? '')
|
||||||
|
).then((r) => showToast(r.message))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('ai.settings.mcpTest')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
|
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||||
<span>{t('ai.settings.customSlashCommands')}</span>
|
<span>{t('ai.settings.customSlashCommands')}</span>
|
||||||
<div className="custom-slash-list" data-testid="custom-slash-commands">
|
<div className="custom-slash-list" data-testid="custom-slash-commands">
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
|
interface CrashReportDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrashReportDialog({ open, onClose }: CrashReportDialogProps): React.JSX.Element | null {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
const handleSend = async (): Promise<void> => {
|
||||||
|
await ipcCall(() => window.electronAPI.log.sendReport())
|
||||||
|
await ipcCall(() => window.electronAPI.log.clearCrash())
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDismiss = async (): Promise<void> => {
|
||||||
|
await ipcCall(() => window.electronAPI.log.clearCrash())
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" data-testid="crash-report-modal" role="dialog" aria-modal="true">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>{t('crash.title')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p>{t('crash.desc')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn btn-primary" data-testid="crash-send-report" onClick={() => void handleSend()}>
|
||||||
|
{t('crash.send')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn" onClick={() => void handleDismiss()}>
|
||||||
|
{t('crash.dismiss')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||||
|
|
||||||
|
export function ExtensionsSettingsTab(): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const settings = useSettingsStore()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="extensions-settings-tab">
|
||||||
|
<h4>{t('extensions.title')}</h4>
|
||||||
|
<p className="text-muted" style={{ marginBottom: 12 }}>
|
||||||
|
{t('extensions.desc')}
|
||||||
|
</p>
|
||||||
|
<div className="setting-item">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="extensions-dev-mode"
|
||||||
|
checked={settings.extensionDevMode === true}
|
||||||
|
onChange={(e) => void settings.update({ extensionDevMode: e.target.checked })}
|
||||||
|
/>
|
||||||
|
{t('extensions.devMode')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted" style={{ fontSize: 12 }}>
|
||||||
|
{t('extensions.stubHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor'
|
|||||||
import { AiSettingsPage } from '@renderer/components/settings/AiSettingsPage'
|
import { AiSettingsPage } from '@renderer/components/settings/AiSettingsPage'
|
||||||
import { TemplateSettingsTab } from '@renderer/components/settings/TemplateSettingsTab'
|
import { TemplateSettingsTab } from '@renderer/components/settings/TemplateSettingsTab'
|
||||||
import { BackupSettingsTab } from '@renderer/components/settings/BackupSettingsTab'
|
import { BackupSettingsTab } from '@renderer/components/settings/BackupSettingsTab'
|
||||||
|
import { SyncSettingsTab } from '@renderer/components/settings/SyncSettingsTab'
|
||||||
|
import { ExtensionsSettingsTab } from '@renderer/components/settings/ExtensionsSettingsTab'
|
||||||
import { SubmissionPresetTab } from '@renderer/components/settings/SubmissionPresetTab'
|
import { SubmissionPresetTab } from '@renderer/components/settings/SubmissionPresetTab'
|
||||||
|
|
||||||
const THEMES: { id: ThemeId; key: string }[] = [
|
const THEMES: { id: ThemeId; key: string }[] = [
|
||||||
@@ -22,7 +24,7 @@ export function SettingsPage(): React.JSX.Element {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const settings = useSettingsStore()
|
const settings = useSettingsStore()
|
||||||
const [section, setSection] = useState<
|
const [section, setSection] = useState<
|
||||||
'general' | 'ai' | 'templates' | 'submission' | 'backup' | 'shortcuts' | 'about'
|
'general' | 'ai' | 'templates' | 'submission' | 'backup' | 'extensions' | 'shortcuts' | 'about'
|
||||||
>('general')
|
>('general')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,6 +74,15 @@ export function SettingsPage(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
{t('settings.backup')}
|
{t('settings.backup')}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className={`settings-nav-item ${section === 'extensions' ? 'active' : ''}`}
|
||||||
|
onClick={() => setSection('extensions')}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
data-testid="settings-nav-extensions"
|
||||||
|
>
|
||||||
|
{t('settings.extensions')}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`settings-nav-item ${section === 'shortcuts' ? 'active' : ''}`}
|
className={`settings-nav-item ${section === 'shortcuts' ? 'active' : ''}`}
|
||||||
onClick={() => setSection('shortcuts')}
|
onClick={() => setSection('shortcuts')}
|
||||||
@@ -305,11 +316,18 @@ export function SettingsPage(): React.JSX.Element {
|
|||||||
{section === 'ai' && <AiSettingsPage />}
|
{section === 'ai' && <AiSettingsPage />}
|
||||||
{section === 'templates' && <TemplateSettingsTab />}
|
{section === 'templates' && <TemplateSettingsTab />}
|
||||||
{section === 'submission' && <SubmissionPresetTab />}
|
{section === 'submission' && <SubmissionPresetTab />}
|
||||||
{section === 'backup' && <BackupSettingsTab />}
|
{section === 'backup' && (
|
||||||
|
<>
|
||||||
|
<BackupSettingsTab />
|
||||||
|
<hr style={{ margin: '24px 0', borderColor: 'var(--border-color)' }} />
|
||||||
|
<SyncSettingsTab />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{section === 'extensions' && <ExtensionsSettingsTab />}
|
||||||
{section === 'shortcuts' && <ShortcutEditor />}
|
{section === 'shortcuts' && <ShortcutEditor />}
|
||||||
{section === 'about' && (
|
{section === 'about' && (
|
||||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||||
{t('app.name')} v1.5.0
|
{t('app.name')} v2.0.0
|
||||||
<br />
|
<br />
|
||||||
{t('app.tagline')}
|
{t('app.tagline')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface SyncConflictModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onChoose: (choice: 'local' | 'remote' | 'cancel') => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncConflictModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onChoose
|
||||||
|
}: SyncConflictModalProps): React.JSX.Element | null {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" data-testid="sync-conflict-modal" role="dialog" aria-modal="true">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>{t('sync.conflictTitle')}</h3>
|
||||||
|
<button type="button" className="modal-close" onClick={onClose}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p>{t('sync.conflictDesc')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn" onClick={() => onChoose('remote')}>
|
||||||
|
{t('sync.useRemote')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => onChoose('local')}>
|
||||||
|
{t('sync.useLocal')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn" onClick={() => onChoose('cancel')}>
|
||||||
|
{t('dialog.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { SyncConfig, SyncMode, SyncProgressEvent, SyncSchedule } from '@shared/sync'
|
||||||
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
import { SyncConflictModal } from '@renderer/components/settings/SyncConflictModal'
|
||||||
|
|
||||||
|
export function SyncSettingsTab(): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const showToast = useAppStore((s) => s.showToast)
|
||||||
|
const [mode, setMode] = useState<SyncMode>('local-folder')
|
||||||
|
const [schedule, setSchedule] = useState<SyncSchedule>('manual')
|
||||||
|
const [localFolderPath, setLocalFolderPath] = useState('')
|
||||||
|
const [webdavUrl, setWebdavUrl] = useState('')
|
||||||
|
const [webdavUser, setWebdavUser] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [syncing, setSyncing] = useState(false)
|
||||||
|
const [progress, setProgress] = useState<SyncProgressEvent | null>(null)
|
||||||
|
const [status, setStatus] = useState<SyncConfig | null>(null)
|
||||||
|
const [conflictOpen, setConflictOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void ipcCall(() => window.electronAPI.sync.status()).then((cfg) => {
|
||||||
|
if (!cfg) return
|
||||||
|
setStatus(cfg)
|
||||||
|
setMode(cfg.mode)
|
||||||
|
setSchedule(cfg.schedule)
|
||||||
|
setLocalFolderPath(cfg.localFolderPath ?? '')
|
||||||
|
setWebdavUrl(cfg.webdavUrl ?? '')
|
||||||
|
setWebdavUser(cfg.webdavUser ?? '')
|
||||||
|
})
|
||||||
|
const unsub = window.electronAPI.onSyncProgress(setProgress)
|
||||||
|
return unsub
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const buildConfig = (): SyncConfig => ({
|
||||||
|
mode,
|
||||||
|
schedule,
|
||||||
|
localFolderPath: localFolderPath || undefined,
|
||||||
|
webdavUrl: webdavUrl || undefined,
|
||||||
|
webdavUser: webdavUser || undefined,
|
||||||
|
syncSalt: status?.syncSalt,
|
||||||
|
passwordConfigured: status?.passwordConfigured,
|
||||||
|
lastSyncAt: status?.lastSyncAt,
|
||||||
|
lastSyncStatus: status?.lastSyncStatus
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSaveConfig = async (): Promise<void> => {
|
||||||
|
if (password.length < 8) {
|
||||||
|
showToast(t('sync.passwordHint'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const cfg = await ipcCall(() =>
|
||||||
|
window.electronAPI.sync.configure({ config: buildConfig(), password })
|
||||||
|
)
|
||||||
|
setStatus(cfg)
|
||||||
|
showToast(t('sync.configSaved'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePickFolder = async (): Promise<void> => {
|
||||||
|
const path = await ipcCall(() => window.electronAPI.sync.pickFolder())
|
||||||
|
if (path) setLocalFolderPath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runSync = async (conflictResolution?: 'local' | 'remote' | 'cancel'): Promise<void> => {
|
||||||
|
if (password.length < 8 && !status?.passwordConfigured) {
|
||||||
|
showToast(t('sync.passwordHint'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSyncing(true)
|
||||||
|
setProgress(null)
|
||||||
|
try {
|
||||||
|
const result = await ipcCall(() =>
|
||||||
|
window.electronAPI.sync.run({ password, conflictResolution })
|
||||||
|
)
|
||||||
|
if (result.status === 'conflict') {
|
||||||
|
setConflictOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus((s) => (s ? { ...s, lastSyncStatus: result.status, lastSyncAt: result.syncedAt } : s))
|
||||||
|
showToast(t('sync.done'))
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setSyncing(false)
|
||||||
|
setProgress(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sync-settings" data-testid="sync-settings-tab">
|
||||||
|
<h4>{t('sync.title')}</h4>
|
||||||
|
<p className="text-muted" style={{ marginBottom: 12 }}>
|
||||||
|
{t('sync.desc')}
|
||||||
|
</p>
|
||||||
|
<div className="setting-item">
|
||||||
|
<span>{t('sync.mode')}</span>
|
||||||
|
<select
|
||||||
|
className="form-control"
|
||||||
|
data-testid="sync-mode"
|
||||||
|
value={mode}
|
||||||
|
onChange={(e) => setMode(e.target.value as SyncMode)}
|
||||||
|
>
|
||||||
|
<option value="local-folder">{t('sync.modeLocal')}</option>
|
||||||
|
<option value="webdav">{t('sync.modeWebdav')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{mode === 'local-folder' ? (
|
||||||
|
<div className="setting-item" style={{ gap: 8 }}>
|
||||||
|
<input
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
data-testid="sync-local-path"
|
||||||
|
value={localFolderPath}
|
||||||
|
onChange={(e) => setLocalFolderPath(e.target.value)}
|
||||||
|
placeholder={t('sync.localPathPlaceholder')}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn" onClick={() => void handlePickFolder()}>
|
||||||
|
{t('sync.pickFolder')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="setting-item">
|
||||||
|
<input
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
data-testid="sync-webdav-url"
|
||||||
|
value={webdavUrl}
|
||||||
|
onChange={(e) => setWebdavUrl(e.target.value)}
|
||||||
|
placeholder={t('sync.webdavUrl')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="setting-item">
|
||||||
|
<input
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
data-testid="sync-webdav-user"
|
||||||
|
value={webdavUser}
|
||||||
|
onChange={(e) => setWebdavUser(e.target.value)}
|
||||||
|
placeholder={t('sync.webdavUser')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="setting-item">
|
||||||
|
<span>{t('sync.schedule')}</span>
|
||||||
|
<select
|
||||||
|
className="form-control"
|
||||||
|
data-testid="sync-schedule"
|
||||||
|
value={schedule}
|
||||||
|
onChange={(e) => setSchedule(e.target.value as SyncSchedule)}
|
||||||
|
>
|
||||||
|
<option value="manual">{t('sync.scheduleManual')}</option>
|
||||||
|
<option value="hourly">{t('sync.scheduleHourly')}</option>
|
||||||
|
<option value="daily">{t('sync.scheduleDaily')}</option>
|
||||||
|
<option value="weekly">{t('sync.scheduleWeekly')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="setting-item">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
data-testid="sync-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={t('sync.passwordPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="setting-item" style={{ gap: 8 }}>
|
||||||
|
<button type="button" className="btn" data-testid="sync-save-config" onClick={() => void handleSaveConfig()}>
|
||||||
|
{t('sync.saveConfig')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
data-testid="sync-run-now"
|
||||||
|
disabled={syncing}
|
||||||
|
onClick={() => void runSync()}
|
||||||
|
>
|
||||||
|
{syncing ? t('sync.running') : t('sync.runNow')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{status?.lastSyncAt && (
|
||||||
|
<p className="text-muted" data-testid="sync-last-status" style={{ fontSize: 12 }}>
|
||||||
|
{t('sync.lastSync', {
|
||||||
|
time: new Date(status.lastSyncAt).toLocaleString(),
|
||||||
|
status: status.lastSyncStatus ?? 'idle'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{progress && (
|
||||||
|
<div className="import-progress" data-testid="sync-progress">
|
||||||
|
<div className="import-progress-fill" style={{ width: `${progress.percent ?? 0}%` }} />
|
||||||
|
<span>{progress.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SyncConflictModal
|
||||||
|
open={conflictOpen}
|
||||||
|
onClose={() => setConflictOpen(false)}
|
||||||
|
onChoose={(choice) => {
|
||||||
|
setConflictOpen(false)
|
||||||
|
void runSync(choice)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
|
export function UpdateBanner(): React.JSX.Element | null {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [version, setVersion] = useState<string | null>(null)
|
||||||
|
const [downloading, setDownloading] = useState(false)
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
const [percent, setPercent] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubAvail = window.electronAPI.onUpdateAvailable((p) => setVersion(p.version))
|
||||||
|
const unsubProg = window.electronAPI.onUpdateProgress((p) => setPercent(Math.round(p.percent)))
|
||||||
|
const unsubDone = window.electronAPI.onUpdateDownloaded(() => {
|
||||||
|
setDownloading(false)
|
||||||
|
setReady(true)
|
||||||
|
})
|
||||||
|
void ipcCall(() => window.electronAPI.update.check()).then((info) => {
|
||||||
|
if (info?.version) setVersion(info.version)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
unsubAvail()
|
||||||
|
unsubProg()
|
||||||
|
unsubDone()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!version) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="update-banner" data-testid="update-banner">
|
||||||
|
<span>{t('update.available', { version })}</span>
|
||||||
|
{!ready ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
data-testid="update-download"
|
||||||
|
disabled={downloading}
|
||||||
|
onClick={() => {
|
||||||
|
setDownloading(true)
|
||||||
|
void ipcCall(() => window.electronAPI.update.download())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{downloading ? t('update.downloading', { percent }) : t('update.download')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
data-testid="update-install"
|
||||||
|
onClick={() => void ipcCall(() => window.electronAPI.update.install())}
|
||||||
|
>
|
||||||
|
{t('update.install')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -38,6 +38,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|||||||
enableComplianceCheck: false,
|
enableComplianceCheck: false,
|
||||||
complianceWords: [] as string[],
|
complianceWords: [] as string[],
|
||||||
customSlashCommands: [] as { id: string; name: string; template: string }[],
|
customSlashCommands: [] as { id: string; name: string; template: string }[],
|
||||||
|
mcpConnectorUrl: '',
|
||||||
|
extensionDevMode: false,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
load: async () => {
|
load: async () => {
|
||||||
const data = await ipcCall(() => window.electronAPI.settings.get())
|
const data = await ipcCall(() => window.electronAPI.settings.get())
|
||||||
|
|||||||
@@ -2574,3 +2574,25 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.update-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--accent-muted, rgba(59, 130, 246, 0.12));
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-settings h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builtin-skill-list {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+50
@@ -64,6 +64,15 @@ import type { ExportMatrixParams } from './export-matrix'
|
|||||||
import type { TimelineEntry, TimelineConflict, TimelineUpsertManualInput } from './timeline'
|
import type { TimelineEntry, TimelineConflict, TimelineUpsertManualInput } from './timeline'
|
||||||
import type { CharacterArcNode } from './arc'
|
import type { CharacterArcNode } from './arc'
|
||||||
import type { ComplianceScanResult } from './compliance'
|
import type { ComplianceScanResult } from './compliance'
|
||||||
|
import type { UndoOperation, UndoStackEntry } from './undo'
|
||||||
|
import type {
|
||||||
|
SyncConfig,
|
||||||
|
SyncConfigureInput,
|
||||||
|
SyncProgressEvent,
|
||||||
|
SyncRunInput,
|
||||||
|
SyncRunResult
|
||||||
|
} from './sync'
|
||||||
|
import type { ExtensionManifestEntry } from './extension-api'
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
window: {
|
window: {
|
||||||
@@ -467,6 +476,39 @@ export interface ElectronAPI {
|
|||||||
setWords: (words: string[]) => Promise<IpcResult<void>>
|
setWords: (words: string[]) => Promise<IpcResult<void>>
|
||||||
checkText: (text: string) => Promise<IpcResult<ComplianceScanResult>>
|
checkText: (text: string) => Promise<IpcResult<ComplianceScanResult>>
|
||||||
}
|
}
|
||||||
|
undo: {
|
||||||
|
push: (
|
||||||
|
bookId: string,
|
||||||
|
chapterId: string,
|
||||||
|
operation: UndoOperation
|
||||||
|
) => Promise<IpcResult<UndoStackEntry>>
|
||||||
|
list: (bookId: string, chapterId: string) => Promise<IpcResult<UndoStackEntry[]>>
|
||||||
|
apply: (bookId: string, entryId: number) => Promise<IpcResult<UndoOperation>>
|
||||||
|
}
|
||||||
|
sync: {
|
||||||
|
status: () => Promise<IpcResult<SyncConfig | null>>
|
||||||
|
configure: (input: SyncConfigureInput) => Promise<IpcResult<SyncConfig>>
|
||||||
|
run: (input: SyncRunInput) => Promise<IpcResult<SyncRunResult>>
|
||||||
|
pickFolder: () => Promise<IpcResult<string | null>>
|
||||||
|
}
|
||||||
|
update: {
|
||||||
|
check: () => Promise<IpcResult<{ version: string; releaseNotes?: string } | null>>
|
||||||
|
download: () => Promise<IpcResult<void>>
|
||||||
|
install: () => Promise<IpcResult<void>>
|
||||||
|
}
|
||||||
|
log: {
|
||||||
|
hadCrash: () => Promise<IpcResult<boolean>>
|
||||||
|
clearCrash: () => Promise<IpcResult<void>>
|
||||||
|
sendReport: (note?: string) => Promise<IpcResult<{ ok: true; loggedAt: string }>>
|
||||||
|
writeError: (message: string) => Promise<IpcResult<void>>
|
||||||
|
}
|
||||||
|
extension: {
|
||||||
|
list: () => Promise<IpcResult<ExtensionManifestEntry[]>>
|
||||||
|
validate: (manifest: unknown) => Promise<IpcResult<ExtensionManifestEntry>>
|
||||||
|
}
|
||||||
|
mcp: {
|
||||||
|
testConnection: (url: string) => Promise<IpcResult<{ ok: boolean; message: string }>>
|
||||||
|
}
|
||||||
pack: {
|
pack: {
|
||||||
pickFile: (
|
pickFile: (
|
||||||
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
|
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
|
||||||
@@ -505,6 +547,14 @@ export interface ElectronAPI {
|
|||||||
onGoalNotification: (callback: (payload: GoalNotificationPayload) => void) => () => void
|
onGoalNotification: (callback: (payload: GoalNotificationPayload) => void) => () => void
|
||||||
onImportProgress: (callback: (payload: { current: number; total: number }) => void) => () => void
|
onImportProgress: (callback: (payload: { current: number; total: number }) => void) => () => void
|
||||||
onPackProgress: (callback: (payload: PackProgressEvent) => void) => () => void
|
onPackProgress: (callback: (payload: PackProgressEvent) => void) => () => void
|
||||||
|
onSyncProgress: (callback: (payload: SyncProgressEvent) => void) => () => void
|
||||||
|
onUpdateAvailable: (
|
||||||
|
callback: (payload: { version: string; releaseNotes?: string }) => void
|
||||||
|
) => () => void
|
||||||
|
onUpdateProgress: (
|
||||||
|
callback: (payload: { percent: number; transferred: number; total: number }) => void
|
||||||
|
) => () => void
|
||||||
|
onUpdateDownloaded: (callback: (payload: { version: string }) => void) => () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export interface SidePanelConfig {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImporterConfig {
|
||||||
|
id: string
|
||||||
|
extensions: string[]
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExporterConfig {
|
||||||
|
id: string
|
||||||
|
extensions: string[]
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIBackendConfig {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillConfig {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorExtensionConfig {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuItemConfig {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
location: 'toolbar' | 'context'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BilinExtension {
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
description: string
|
||||||
|
main: string
|
||||||
|
permissions: string[]
|
||||||
|
contributes: {
|
||||||
|
sidePanels?: SidePanelConfig[]
|
||||||
|
importers?: ImporterConfig[]
|
||||||
|
exporters?: ExporterConfig[]
|
||||||
|
aiBackends?: AIBackendConfig[]
|
||||||
|
skills?: SkillConfig[]
|
||||||
|
editorExtensions?: EditorExtensionConfig[]
|
||||||
|
menuItems?: MenuItemConfig[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionManifestEntry {
|
||||||
|
id: string
|
||||||
|
manifest: BilinExtension
|
||||||
|
sourcePath?: string
|
||||||
|
valid: boolean
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
@@ -168,5 +168,27 @@ export const IPC = {
|
|||||||
ARC_GET_FOR_CHARACTER: 'arc:getForCharacter',
|
ARC_GET_FOR_CHARACTER: 'arc:getForCharacter',
|
||||||
COMPLIANCE_GET_WORDS: 'compliance:getWords',
|
COMPLIANCE_GET_WORDS: 'compliance:getWords',
|
||||||
COMPLIANCE_SET_WORDS: 'compliance:setWords',
|
COMPLIANCE_SET_WORDS: 'compliance:setWords',
|
||||||
COMPLIANCE_CHECK_TEXT: 'compliance:checkText'
|
COMPLIANCE_CHECK_TEXT: 'compliance:checkText',
|
||||||
|
UNDO_PUSH: 'undo:push',
|
||||||
|
UNDO_LIST: 'undo:list',
|
||||||
|
UNDO_APPLY: 'undo:apply',
|
||||||
|
SYNC_CONFIGURE: 'sync:configure',
|
||||||
|
SYNC_RUN: 'sync:run',
|
||||||
|
SYNC_STATUS: 'sync:status',
|
||||||
|
SYNC_PICK_FOLDER: 'sync:pickFolder',
|
||||||
|
SYNC_PROGRESS: 'sync:progress',
|
||||||
|
UPDATE_CHECK: 'update:check',
|
||||||
|
UPDATE_DOWNLOAD: 'update:download',
|
||||||
|
UPDATE_INSTALL: 'update:install',
|
||||||
|
UPDATE_AVAILABLE: 'update:available',
|
||||||
|
UPDATE_PROGRESS: 'update:progress',
|
||||||
|
UPDATE_DOWNLOADED: 'update:downloaded',
|
||||||
|
UPDATE_ERROR: 'update:error',
|
||||||
|
LOG_HAD_CRASH: 'log:hadCrash',
|
||||||
|
LOG_CLEAR_CRASH: 'log:clearCrash',
|
||||||
|
LOG_SEND_REPORT: 'log:sendReport',
|
||||||
|
LOG_WRITE_ERROR: 'log:writeError',
|
||||||
|
EXTENSION_LIST: 'extension:list',
|
||||||
|
EXTENSION_VALIDATE: 'extension:validate',
|
||||||
|
MCP_TEST_CONNECTION: 'mcp:testConnection'
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export type SyncMode = 'webdav' | 'local-folder'
|
||||||
|
export type SyncSchedule = 'manual' | 'hourly' | 'daily' | 'weekly'
|
||||||
|
export type SyncStatus = 'ok' | 'error' | 'syncing' | 'conflict' | 'idle'
|
||||||
|
|
||||||
|
export interface SyncConfig {
|
||||||
|
mode: SyncMode
|
||||||
|
webdavUrl?: string
|
||||||
|
webdavUser?: string
|
||||||
|
localFolderPath?: string
|
||||||
|
schedule: SyncSchedule
|
||||||
|
lastSyncAt?: string | null
|
||||||
|
lastSyncStatus?: SyncStatus
|
||||||
|
syncSalt?: string
|
||||||
|
passwordConfigured?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncProgressEvent {
|
||||||
|
phase: 'export' | 'encrypt' | 'upload' | 'download' | 'decrypt' | 'import' | 'done'
|
||||||
|
message?: string
|
||||||
|
percent?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncRunResult {
|
||||||
|
status: SyncStatus
|
||||||
|
syncedAt: string
|
||||||
|
remoteNewer?: boolean
|
||||||
|
localNewer?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncConfigureInput {
|
||||||
|
config: SyncConfig
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncRunInput {
|
||||||
|
password: string
|
||||||
|
conflictResolution?: 'local' | 'remote' | 'cancel'
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { SyncConfig } from './sync'
|
||||||
|
|
||||||
export type ThemeId =
|
export type ThemeId =
|
||||||
| 'default'
|
| 'default'
|
||||||
| 'bamboo'
|
| 'bamboo'
|
||||||
@@ -295,6 +297,11 @@ export interface GlobalSettings {
|
|||||||
enableComplianceCheck?: boolean
|
enableComplianceCheck?: boolean
|
||||||
complianceWords?: string[]
|
complianceWords?: string[]
|
||||||
customSlashCommands?: CustomSlashCommand[]
|
customSlashCommands?: CustomSlashCommand[]
|
||||||
|
syncConfig?: SyncConfig | null
|
||||||
|
syncPasswordVerifier?: string
|
||||||
|
extensionDevMode?: boolean
|
||||||
|
mcpConnectorUrl?: string
|
||||||
|
pendingCrashAck?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomSlashCommand {
|
export interface CustomSlashCommand {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export interface UndoOperation {
|
||||||
|
type: 'doc'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UndoStackEntry {
|
||||||
|
id: number
|
||||||
|
chapterId: string
|
||||||
|
operation: UndoOperation
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { LogService } from '../../src/main/services/log.service'
|
||||||
|
|
||||||
|
describe('LogService', () => {
|
||||||
|
let dir: string
|
||||||
|
let logs: LogService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'bilin-log-'))
|
||||||
|
logs = new LogService(dir)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('UT-LOG-01: manual error writes to log file', () => {
|
||||||
|
logs.logManualError('UT-LOG-01 test error')
|
||||||
|
const logFile = join(dir, 'logs', 'bilin.log')
|
||||||
|
expect(existsSync(logFile)).toBe(true)
|
||||||
|
const content = readFileSync(logFile, 'utf-8')
|
||||||
|
expect(content).toContain('UT-LOG-01 test error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects crash flag from previous run', () => {
|
||||||
|
logs.markUncleanExit()
|
||||||
|
const next = new LogService(dir)
|
||||||
|
expect(next.hadCrashOnLastRun()).toBe(true)
|
||||||
|
next.clearCrashFlag()
|
||||||
|
expect(next.hadCrashOnLastRun()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,7 +11,7 @@ describe('migrate v10', () => {
|
|||||||
db = new DatabaseSync(':memory:')
|
db = new DatabaseSync(':memory:')
|
||||||
migrate(db)
|
migrate(db)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(10)
|
expect(version.v).toBe(11)
|
||||||
const table = db
|
const table = db
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='timeline_events'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='timeline_events'")
|
||||||
.get()
|
.get()
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
|
||||||
|
describe('migrate v11', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
afterEach(() => db?.close())
|
||||||
|
|
||||||
|
it('UT-MIG-11: fresh database migrates to v11 with undo_stack', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
|
expect(version.v).toBe(11)
|
||||||
|
const table = db
|
||||||
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='undo_stack'")
|
||||||
|
.get()
|
||||||
|
expect(table).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -30,7 +30,7 @@ describe('migrate v2', () => {
|
|||||||
expect(tableExists(db, 'search_fts')).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 }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(10)
|
expect(version.v).toBe(11)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('migrates existing v1 database to v2', () => {
|
it('migrates existing v1 database to v2', () => {
|
||||||
@@ -47,7 +47,7 @@ describe('migrate v2', () => {
|
|||||||
|
|
||||||
expect(tableExists(db, 'outline_items')).toBe(true)
|
expect(tableExists(db, 'outline_items')).toBe(true)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(10)
|
expect(version.v).toBe(11)
|
||||||
|
|
||||||
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
|
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
|
||||||
const colNames = cols.map((c) => c.name)
|
const colNames = cols.map((c) => c.name)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe('migrate v3', () => {
|
|||||||
expect(tableExists(db, 'ai_messages')).toBe(true)
|
expect(tableExists(db, 'ai_messages')).toBe(true)
|
||||||
|
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(10)
|
expect(version.v).toBe(11)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('migrates existing v2 database to v3', () => {
|
it('migrates existing v2 database to v3', () => {
|
||||||
@@ -49,6 +49,6 @@ describe('migrate v3', () => {
|
|||||||
expect(tables.map((t) => t.name)).toContain('ai_messages')
|
expect(tables.map((t) => t.name)).toContain('ai_messages')
|
||||||
|
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(10)
|
expect(version.v).toBe(11)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe('migrate v4', () => {
|
|||||||
migrate(db)
|
migrate(db)
|
||||||
|
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(10)
|
expect(version.v).toBe(11)
|
||||||
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
||||||
expect(tableExists(db, 'interactive_scenes')).toBe(true)
|
expect(tableExists(db, 'interactive_scenes')).toBe(true)
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ describe('migrate v4', () => {
|
|||||||
migrate(db)
|
migrate(db)
|
||||||
|
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(10)
|
expect(version.v).toBe(11)
|
||||||
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ describe('migrate v5', () => {
|
|||||||
db = new DatabaseSync(':memory:')
|
db = new DatabaseSync(':memory:')
|
||||||
migrate(db)
|
migrate(db)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(10)
|
expect(version.v).toBe(11)
|
||||||
const cols = db.prepare('PRAGMA table_info(interactive_flows)').all() as { name: string }[]
|
const cols = db.prepare('PRAGMA table_info(interactive_flows)').all() as { name: string }[]
|
||||||
expect(cols.some((c) => c.name === 'flow_mode')).toBe(true)
|
expect(cols.some((c) => c.name === 'flow_mode')).toBe(true)
|
||||||
expect(cols.some((c) => c.name === 'mode_config_json')).toBe(true)
|
expect(cols.some((c) => c.name === 'mode_config_json')).toBe(true)
|
||||||
@@ -39,6 +39,6 @@ describe('migrate v5', () => {
|
|||||||
db.prepare('INSERT INTO schema_version (version) VALUES (4)').run()
|
db.prepare('INSERT INTO schema_version (version) VALUES (4)').run()
|
||||||
migrate(db)
|
migrate(db)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(10)
|
expect(version.v).toBe(11)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe('migrate v6', () => {
|
|||||||
db = new DatabaseSync(':memory:')
|
db = new DatabaseSync(':memory:')
|
||||||
migrate(db)
|
migrate(db)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(10)
|
expect(version.v).toBe(11)
|
||||||
const tables = db
|
const tables = db
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
.all() as { name: string }[]
|
.all() as { name: string }[]
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe('migrate v7', () => {
|
|||||||
db = new DatabaseSync(':memory:')
|
db = new DatabaseSync(':memory:')
|
||||||
migrate(db)
|
migrate(db)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(10)
|
expect(version.v).toBe(11)
|
||||||
const tables = db
|
const tables = db
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_writing_snapshots'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_writing_snapshots'")
|
||||||
.get()
|
.get()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe('migrate v8', () => {
|
|||||||
db = new DatabaseSync(':memory:')
|
db = new DatabaseSync(':memory:')
|
||||||
migrate(db)
|
migrate(db)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(10)
|
expect(version.v).toBe(11)
|
||||||
const table = db
|
const table = db
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='knowledge_injection_logs'"
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='knowledge_injection_logs'"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe('migrate v9', () => {
|
|||||||
db = new DatabaseSync(':memory:')
|
db = new DatabaseSync(':memory:')
|
||||||
migrate(db)
|
migrate(db)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(10)
|
expect(version.v).toBe(11)
|
||||||
const table = db
|
const table = db
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_tags'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_tags'")
|
||||||
.get()
|
.get()
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { SyncService } from '../../src/main/services/sync.service'
|
||||||
|
import { GlobalSettingsService } from '../../src/main/services/global-settings'
|
||||||
|
import { BookRegistryService } from '../../src/main/services/book-registry'
|
||||||
|
import { SYNC_BUNDLE_NAME } from '../../src/main/services/sync-crypto'
|
||||||
|
import { closeAllBookDbs } from '../../src/main/db/connection'
|
||||||
|
|
||||||
|
describe('SyncService', () => {
|
||||||
|
let userDir: string
|
||||||
|
let sync: SyncService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
userDir = mkdtempSync(join(tmpdir(), 'bilin-sync-svc-'))
|
||||||
|
const settings = new GlobalSettingsService(userDir)
|
||||||
|
const registry = new BookRegistryService(userDir)
|
||||||
|
registry.create({ name: '同步测试书', category: '玄幻', createSampleChapter: false })
|
||||||
|
sync = new SyncService(registry, settings, userDir)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
closeAllBookDbs()
|
||||||
|
try {
|
||||||
|
rmSync(userDir, { recursive: true, force: true })
|
||||||
|
} catch {
|
||||||
|
/* windows file lock */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IT-SYNC-01: local-folder sync writes encrypted bundle', async () => {
|
||||||
|
const syncDir = mkdtempSync(join(tmpdir(), 'bilin-sync-target-'))
|
||||||
|
try {
|
||||||
|
sync.configure({
|
||||||
|
config: {
|
||||||
|
mode: 'local-folder',
|
||||||
|
localFolderPath: syncDir,
|
||||||
|
schedule: 'manual'
|
||||||
|
},
|
||||||
|
password: 'test-password-1'
|
||||||
|
})
|
||||||
|
const result = await sync.run('test-password-1')
|
||||||
|
expect(result.status).toBe('ok')
|
||||||
|
expect(existsSync(join(syncDir, SYNC_BUNDLE_NAME))).toBe(true)
|
||||||
|
} finally {
|
||||||
|
rmSync(syncDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { UndoStackService } from '../../src/main/services/undo-stack.service'
|
||||||
|
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('UndoStackService', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
let svc: UndoStackService
|
||||||
|
let chapterId: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
svc = new UndoStackService(db)
|
||||||
|
const vol = new VolumeRepository(db).create('卷一', 0)
|
||||||
|
chapterId = new ChapterRepository(db).create(vol.id, '第一章', 0).id
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
db.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('UT-UNDO-01: push and list undo entries', () => {
|
||||||
|
svc.push(chapterId, { type: 'doc', content: '<p>旧内容</p>' })
|
||||||
|
svc.push(chapterId, { type: 'doc', content: '<p>新内容</p>' })
|
||||||
|
const list = svc.list(chapterId)
|
||||||
|
expect(list).toHaveLength(2)
|
||||||
|
expect(list[0]?.operation.content).toContain('新内容')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { UpdateService } from '../../src/main/services/update.service'
|
||||||
|
|
||||||
|
describe('UpdateService', () => {
|
||||||
|
const prevMock = process.env.BILIN_MOCK_UPDATE
|
||||||
|
const prevVersion = process.env.BILIN_MOCK_UPDATE_VERSION
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.BILIN_MOCK_UPDATE = '1'
|
||||||
|
process.env.BILIN_MOCK_UPDATE_VERSION = '9.9.9'
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (prevMock === undefined) delete process.env.BILIN_MOCK_UPDATE
|
||||||
|
else process.env.BILIN_MOCK_UPDATE = prevMock
|
||||||
|
if (prevVersion === undefined) delete process.env.BILIN_MOCK_UPDATE_VERSION
|
||||||
|
else process.env.BILIN_MOCK_UPDATE_VERSION = prevVersion
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IT-UPDATE-01: mock check returns available version', async () => {
|
||||||
|
const svc = new UpdateService()
|
||||||
|
const info = await svc.check()
|
||||||
|
expect(info?.version).toBe('9.9.9')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user