diff --git a/README.md b/README.md index 42971dd..cfb73ea 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,14 @@ # 笔临 (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) diff --git a/docs/extensions/README.md b/docs/extensions/README.md new file mode 100644 index 0000000..12f196f --- /dev/null +++ b/docs/extensions/README.md @@ -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`。 diff --git a/docs/superpowers/plans/2026-07-08-bilin-w5-platform.md b/docs/superpowers/plans/2026-07-08-bilin-w5-platform.md new file mode 100644 index 0000000..e77d5e2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-bilin-w5-platform.md @@ -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** diff --git a/e2e/sync.spec.ts b/e2e/sync.spec.ts new file mode 100644 index 0000000..72a054a --- /dev/null +++ b/e2e/sync.spec.ts @@ -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 }) + }) +}) diff --git a/e2e/undo.spec.ts b/e2e/undo.spec.ts new file mode 100644 index 0000000..86b5bcb --- /dev/null +++ b/e2e/undo.spec.ts @@ -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() + }) +}) diff --git a/e2e/update.spec.ts b/e2e/update.spec.ts new file mode 100644 index 0000000..b0ea31d --- /dev/null +++ b/e2e/update.spec.ts @@ -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() + }) +}) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index a15076c..dbae612 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -11,9 +11,10 @@ export default defineConfig({ index: resolve(__dirname, 'src/main/index.ts'), 'import-bootstrap': resolve(__dirname, 'src/main/import-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'] } } }, diff --git a/package-lock.json b/package-lock.json index bd990be..4181389 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bilin", - "version": "1.3.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bilin", - "version": "1.3.0", + "version": "1.5.0", "license": "MIT", "dependencies": { "@radix-ui/react-checkbox": "^1.3.6", @@ -24,6 +24,8 @@ "diff-match-patch": "^1.0.5", "docx": "^9.7.1", "electron": "^36.9.0", + "electron-log": "^5.4.4", + "electron-updater": "^6.8.9", "extract-zip": "^2.0.1", "i18next": "^24.2.3", "jotai": "^2.12.1", @@ -3724,7 +3726,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -5369,6 +5370,15 @@ "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": { "version": "25.1.7", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz", @@ -5392,6 +5402,47 @@ "devOptional": true, "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-3.1.0.tgz", @@ -5830,7 +5881,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -6608,7 +6658,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, "funding": [ { "type": "github", @@ -6677,7 +6726,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -6747,7 +6795,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/lazystream": { @@ -6830,6 +6877,12 @@ "license": "MIT", "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": { "version": "4.4.0", "resolved": "https://registry.npmmirror.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz", @@ -6838,6 +6891,13 @@ "license": "MIT", "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": { "version": "4.0.6", "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": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -9130,7 +9196,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" diff --git a/package.json b/package.json index 73dbfa1..efc90ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bilin", - "version": "1.5.0", + "version": "2.0.0", "description": "笔临 - 长篇创作智能协作平台", "main": "./out/main/index.js", "type": "module", @@ -34,6 +34,8 @@ "diff-match-patch": "^1.0.5", "docx": "^9.7.1", "electron": "^36.9.0", + "electron-log": "^5.4.4", + "electron-updater": "^6.8.9", "extract-zip": "^2.0.1", "i18next": "^24.2.3", "jotai": "^2.12.1", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 7c6098a..043f8af 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -589,5 +589,50 @@ "readMode.fontSerif": "Serif", "readMode.fontSans": "Sans-serif", "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" } diff --git a/public/locales/zh-CN/translation.json b/public/locales/zh-CN/translation.json index a8b70c2..48ee10e 100644 --- a/public/locales/zh-CN/translation.json +++ b/public/locales/zh-CN/translation.json @@ -589,5 +589,50 @@ "readMode.fontSerif": "衬线", "readMode.fontSans": "无衬线", "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": "测试连接" } diff --git a/src/main/db/migrate.ts b/src/main/db/migrate.ts index 1ffdfe3..f4448b4 100644 --- a/src/main/db/migrate.ts +++ b/src/main/db/migrate.ts @@ -10,8 +10,9 @@ import schemaV8 from './schema-v8.sql?raw' import schemaV9 from './schema-v9.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 { try { @@ -105,4 +106,9 @@ export function migrate(db: SqliteDb): void { db.exec(schemaV10) 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') + } } diff --git a/src/main/db/repositories/undo.repo.ts b/src/main/db/repositories/undo.repo.ts new file mode 100644 index 0000000..ab89a34 --- /dev/null +++ b/src/main/db/repositories/undo.repo.ts @@ -0,0 +1,61 @@ +import type { UndoOperation, UndoStackEntry } from '../../../shared/undo' +import type { SqliteDb } from '../types' + +const MAX_ENTRIES = 50 + +function mapRow(row: Record): 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[] + ).map(mapRow) + } + + get(id: number): UndoStackEntry | null { + const row = this.db.prepare('SELECT * FROM undo_stack WHERE id = ?').get(id) as + | Record + | 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) + } + } +} diff --git a/src/main/db/schema-v11.sql b/src/main/db/schema-v11.sql new file mode 100644 index 0000000..61bcd7b --- /dev/null +++ b/src/main/db/schema-v11.sql @@ -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); diff --git a/src/main/index.ts b/src/main/index.ts index 6a0b80d..ab8ac0e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -58,6 +58,8 @@ app.whenReady().then(async () => { bootstrapPackHandlers(books, settings) const { bootstrapExportMatrixHandlers } = await import('./export-matrix-bootstrap.js') bootstrapExportMatrixHandlers(books, settings) + const { bootstrapSyncHandlers } = await import('./sync-bootstrap.js') + bootstrapSyncHandlers(books, settings) ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => { BrowserWindow.fromWebContents(event.sender)?.minimize() diff --git a/src/main/ipc/handlers/log.handler.ts b/src/main/ipc/handlers/log.handler.ts new file mode 100644 index 0000000..4f23a68 --- /dev/null +++ b/src/main/ipc/handlers/log.handler.ts @@ -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'}` })) + ) +} diff --git a/src/main/ipc/handlers/sync.handler.ts b/src/main/ipc/handlers/sync.handler.ts new file mode 100644 index 0000000..05f0906 --- /dev/null +++ b/src/main/ipc/handlers/sync.handler.ts @@ -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] + }) + ) +} diff --git a/src/main/ipc/handlers/undo.handler.ts b/src/main/ipc/handlers/undo.handler.ts new file mode 100644 index 0000000..2ee421a --- /dev/null +++ b/src/main/ipc/handlers/undo.handler.ts @@ -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 + }) + ) +} diff --git a/src/main/ipc/handlers/update.handler.ts b/src/main/ipc/handlers/update.handler.ts new file mode 100644 index 0000000..b801916 --- /dev/null +++ b/src/main/ipc/handlers/update.handler.ts @@ -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() + }) + ) +} diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index 5fb1f7a..e30d637 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -39,6 +39,10 @@ import { registerExportHandlers } from './handlers/export.handler' import { registerTimelineHandlers } from './handlers/timeline.handler' import { registerArcHandlers } from './handlers/arc.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 { NetworkMonitorService } from '../services/network-monitor.service' @@ -46,10 +50,21 @@ let shortcutManager: ShortcutManager | null = null let snapshotService: SnapshotService | 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 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 writingLogRepo = new WritingLogRepository(userData) const writingLogs = new WritingLogService(writingLogRepo, settings) @@ -99,8 +114,12 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg registerTimelineHandlers(books) registerArcHandlers(books) registerComplianceHandlers(settings) + registerUndoHandlers(books) + registerLogHandlers(logs) + registerExtensionHandlers() + registerUpdateHandlers() - return { settings, books, shortcuts: shortcutManager } + return { settings, books, shortcuts: shortcutManager, logs } } export function getShortcutManager(): ShortcutManager | null { diff --git a/src/main/services/extension-registry.ts b/src/main/services/extension-registry.ts new file mode 100644 index 0000000..4c437c3 --- /dev/null +++ b/src/main/services/extension-registry.ts @@ -0,0 +1,40 @@ +import type { BilinExtension, ExtensionManifestEntry } from '../../shared/extension-api' + +export class ExtensionRegistry { + private extensions = new Map() + + 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() diff --git a/src/main/services/global-settings.ts b/src/main/services/global-settings.ts index 06ce8fa..6732432 100644 --- a/src/main/services/global-settings.ts +++ b/src/main/services/global-settings.ts @@ -41,7 +41,10 @@ function defaults(): GlobalSettings { defaultSubmissionPresetId: 'builtin-webnovel', enableComplianceCheck: false, complianceWords: [], - customSlashCommands: [] + customSlashCommands: [], + syncConfig: null, + extensionDevMode: false, + mcpConnectorUrl: '' } } @@ -76,6 +79,10 @@ export class GlobalSettingsService { enableComplianceCheck: raw.enableComplianceCheck ?? false, complianceWords: raw.complianceWords ?? [], customSlashCommands: raw.customSlashCommands ?? [], + syncConfig: raw.syncConfig ?? null, + syncPasswordVerifier: raw.syncPasswordVerifier, + extensionDevMode: raw.extensionDevMode ?? false, + mcpConnectorUrl: raw.mcpConnectorUrl ?? '', shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts }, aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig } } diff --git a/src/main/services/log.service.ts b/src/main/services/log.service.ts new file mode 100644 index 0000000..f8aecf6 --- /dev/null +++ b/src/main/services/log.service.ts @@ -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 */ + } + } + } +} diff --git a/src/main/services/sync-crypto.ts b/src/main/services/sync-crypto.ts new file mode 100644 index 0000000..4a3e25f --- /dev/null +++ b/src/main/services/sync-crypto.ts @@ -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' diff --git a/src/main/services/sync.service.ts b/src/main/services/sync.service.ts new file mode 100644 index 0000000..f8612cb --- /dev/null +++ b/src/main/services/sync.service.ts @@ -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 | 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 { + 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 { + 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 { + 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 { + if (cfg.mode === 'local-folder') { + mkdirSync(cfg.localFolderPath!, { recursive: true }) + copyFileSync(localEncPath, remotePath) + return + } + const body = readFileSync(localEncPath) + const headers: Record = { '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 { + if (cfg.mode === 'local-folder') { + copyFileSync(remotePath, destPath) + return + } + const headers: Record = {} + 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) + } +} diff --git a/src/main/services/undo-stack.service.ts b/src/main/services/undo-stack.service.ts new file mode 100644 index 0000000..ad29a10 --- /dev/null +++ b/src/main/services/undo-stack.service.ts @@ -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 + } +} diff --git a/src/main/services/update.service.ts b/src/main/services/update.service.ts new file mode 100644 index 0000000..27b0e70 --- /dev/null +++ b/src/main/services/update.service.ts @@ -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 { + 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 { + 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 */ + } + } +} diff --git a/src/main/sync-bootstrap.ts b/src/main/sync-bootstrap.ts new file mode 100644 index 0000000..3793a1d --- /dev/null +++ b/src/main/sync-bootstrap.ts @@ -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) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 928aaa0..3afac5c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -69,6 +69,15 @@ import type { ExportMatrixParams } from '../shared/export-matrix' import type { TimelineEntry, TimelineConflict, TimelineUpsertManualInput } from '../shared/timeline' import type { CharacterArcNode } from '../shared/arc' 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 = { window: { @@ -635,6 +644,49 @@ const electronAPI = { checkText: (text: string): Promise> => ipcRenderer.invoke(IPC.COMPLIANCE_CHECK_TEXT, { text }) }, + undo: { + push: ( + bookId: string, + chapterId: string, + operation: UndoOperation + ): Promise> => + ipcRenderer.invoke(IPC.UNDO_PUSH, { bookId, chapterId, operation }), + list: (bookId: string, chapterId: string): Promise> => + ipcRenderer.invoke(IPC.UNDO_LIST, { bookId, chapterId }), + apply: (bookId: string, entryId: number): Promise> => + ipcRenderer.invoke(IPC.UNDO_APPLY, { bookId, entryId }) + }, + sync: { + status: (): Promise> => ipcRenderer.invoke(IPC.SYNC_STATUS), + configure: (input: SyncConfigureInput): Promise> => + ipcRenderer.invoke(IPC.SYNC_CONFIGURE, input), + run: (input: SyncRunInput): Promise> => + ipcRenderer.invoke(IPC.SYNC_RUN, input), + pickFolder: (): Promise> => ipcRenderer.invoke(IPC.SYNC_PICK_FOLDER) + }, + update: { + check: (): Promise> => + ipcRenderer.invoke(IPC.UPDATE_CHECK), + download: (): Promise> => ipcRenderer.invoke(IPC.UPDATE_DOWNLOAD), + install: (): Promise> => ipcRenderer.invoke(IPC.UPDATE_INSTALL) + }, + log: { + hadCrash: (): Promise> => ipcRenderer.invoke(IPC.LOG_HAD_CRASH), + clearCrash: (): Promise> => ipcRenderer.invoke(IPC.LOG_CLEAR_CRASH), + sendReport: (note?: string): Promise> => + ipcRenderer.invoke(IPC.LOG_SEND_REPORT, { note }), + writeError: (message: string): Promise> => + ipcRenderer.invoke(IPC.LOG_WRITE_ERROR, { message }) + }, + extension: { + list: (): Promise> => ipcRenderer.invoke(IPC.EXTENSION_LIST), + validate: (manifest: unknown): Promise> => + ipcRenderer.invoke(IPC.EXTENSION_VALIDATE, { manifest }) + }, + mcp: { + testConnection: (url: string): Promise> => + ipcRenderer.invoke(IPC.MCP_TEST_CONNECTION, { url }) + }, pack: { pickFile: ( mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import' @@ -736,6 +788,44 @@ const electronAPI = { } ipcRenderer.on(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) } } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 1d74cdf..42fc278 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -6,6 +6,7 @@ import './styles/themes.css' import './styles/globals.css' import './styles/layout.css' import { TopBar } from '@renderer/components/layout/TopBar' +import { UpdateBanner } from '@renderer/components/settings/UpdateBanner' import { HomePage } from '@renderer/components/home/HomePage' import { EditorLayout } from '@renderer/components/layout/EditorLayout' 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 { useAiStore } from '@renderer/stores/useAiStore' import { useExportStore } from '@renderer/stores/useExportStore' +import { CrashReportDialog } from '@renderer/components/settings/CrashReportDialog' import { ipcCall } from '@renderer/lib/ipc-client' function AppInner(): React.JSX.Element { @@ -48,6 +50,13 @@ function AppInner(): React.JSX.Element { const { activeTabId, openTabs } = useTabStore() const openBook = useBookStore((s) => s.openBook) const [showOnboarding, setShowOnboarding] = useState(false) + const [crashDialogOpen, setCrashDialogOpen] = useState(false) + + useEffect(() => { + void ipcCall(() => window.electronAPI.log.hadCrash()).then((had) => { + if (had) setCrashDialogOpen(true) + }) + }, []) useEffect(() => { void (async () => { @@ -202,6 +211,7 @@ function AppInner(): React.JSX.Element { return (
+
{view === 'home' && } {view === 'editor' && } @@ -215,6 +225,7 @@ function AppInner(): React.JSX.Element { + setCrashDialogOpen(false)} /> setNamingCheckOpen(false)} /> {toast &&
{toast}
}
diff --git a/src/renderer/components/editor/TipTapEditor.tsx b/src/renderer/components/editor/TipTapEditor.tsx index 685a0fa..06b6eae 100644 --- a/src/renderer/components/editor/TipTapEditor.tsx +++ b/src/renderer/components/editor/TipTapEditor.tsx @@ -87,6 +87,13 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E const updateInspirationLocal = useBookStore((s) => s.updateInspirationLocal) const settings = useBookStore((s) => s.settings) const timerRef = useRef>() + const undoTimerRef = useRef>() + const lastPushedContentRef = useRef(null) + const persistentUndoRef = useRef<{ entries: import('@shared/undo').UndoStackEntry[]; index: number }>({ + entries: [], + index: -1 + }) + const editorRef = useRef>(null) const targetKeyRef = useRef(null) const [mentionOpen, setMentionOpen] = useState(false) const [mentionQuery, setMentionQuery] = useState('') @@ -139,6 +146,26 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E } const text = ed.getText() 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') { const ch = chapters.find((c) => c.id === target.id) 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 view.dispatch(view.state.tr.insertText(text, pos)) 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 () => { if (!bookId || !target || !editor) return setSaving(true) @@ -356,6 +397,15 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E targetKeyRef.current = key 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') { const ch = chapters.find((c) => c.id === target.id) const pos = Math.min(ch?.cursorOffset ?? 0, editor.state.doc.content.size) diff --git a/src/renderer/components/settings/AiSettingsPage.tsx b/src/renderer/components/settings/AiSettingsPage.tsx index be7de33..a111bd2 100644 --- a/src/renderer/components/settings/AiSettingsPage.tsx +++ b/src/renderer/components/settings/AiSettingsPage.tsx @@ -148,6 +148,38 @@ export function AiSettingsPage(): React.JSX.Element {

)} +
+ {t('ai.settings.builtinSkills')} +
    +
  • {t('ai.settings.skillContinue')}
  • +
  • {t('ai.settings.skillPolish')}
  • +
  • {t('ai.settings.skillProofread')}
  • +
+
+ +
+ {t('ai.settings.mcpConnector')} + void settings.update({ mcpConnectorUrl: e.target.value })} + placeholder="https://mcp.example.com" + /> + +
+
{t('ai.settings.customSlashCommands')}
diff --git a/src/renderer/components/settings/CrashReportDialog.tsx b/src/renderer/components/settings/CrashReportDialog.tsx new file mode 100644 index 0000000..0fc75d0 --- /dev/null +++ b/src/renderer/components/settings/CrashReportDialog.tsx @@ -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 => { + await ipcCall(() => window.electronAPI.log.sendReport()) + await ipcCall(() => window.electronAPI.log.clearCrash()) + onClose() + } + + const handleDismiss = async (): Promise => { + await ipcCall(() => window.electronAPI.log.clearCrash()) + onClose() + } + + return ( +
+
+
+

{t('crash.title')}

+
+
+

{t('crash.desc')}

+
+
+ + +
+
+
+ ) +} diff --git a/src/renderer/components/settings/ExtensionsSettingsTab.tsx b/src/renderer/components/settings/ExtensionsSettingsTab.tsx new file mode 100644 index 0000000..fa2187b --- /dev/null +++ b/src/renderer/components/settings/ExtensionsSettingsTab.tsx @@ -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 ( +
+

{t('extensions.title')}

+

+ {t('extensions.desc')} +

+
+ +
+

+ {t('extensions.stubHint')} +

+
+ ) +} diff --git a/src/renderer/components/settings/SettingsPage.tsx b/src/renderer/components/settings/SettingsPage.tsx index 77abd91..1acc394 100644 --- a/src/renderer/components/settings/SettingsPage.tsx +++ b/src/renderer/components/settings/SettingsPage.tsx @@ -6,6 +6,8 @@ import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor' import { AiSettingsPage } from '@renderer/components/settings/AiSettingsPage' import { TemplateSettingsTab } from '@renderer/components/settings/TemplateSettingsTab' 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' const THEMES: { id: ThemeId; key: string }[] = [ @@ -22,7 +24,7 @@ export function SettingsPage(): React.JSX.Element { const { t } = useTranslation() const settings = useSettingsStore() const [section, setSection] = useState< - 'general' | 'ai' | 'templates' | 'submission' | 'backup' | 'shortcuts' | 'about' + 'general' | 'ai' | 'templates' | 'submission' | 'backup' | 'extensions' | 'shortcuts' | 'about' >('general') return ( @@ -72,6 +74,15 @@ export function SettingsPage(): React.JSX.Element { > {t('settings.backup')}
+
setSection('extensions')} + role="button" + tabIndex={0} + data-testid="settings-nav-extensions" + > + {t('settings.extensions')} +
setSection('shortcuts')} @@ -305,11 +316,18 @@ export function SettingsPage(): React.JSX.Element { {section === 'ai' && } {section === 'templates' && } {section === 'submission' && } - {section === 'backup' && } + {section === 'backup' && ( + <> + +
+ + + )} + {section === 'extensions' && } {section === 'shortcuts' && } {section === 'about' && (

- {t('app.name')} v1.5.0 + {t('app.name')} v2.0.0
{t('app.tagline')}

diff --git a/src/renderer/components/settings/SyncConflictModal.tsx b/src/renderer/components/settings/SyncConflictModal.tsx new file mode 100644 index 0000000..4a74b38 --- /dev/null +++ b/src/renderer/components/settings/SyncConflictModal.tsx @@ -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 ( +
+
+
+

{t('sync.conflictTitle')}

+ +
+
+

{t('sync.conflictDesc')}

+
+
+ + + +
+
+
+ ) +} diff --git a/src/renderer/components/settings/SyncSettingsTab.tsx b/src/renderer/components/settings/SyncSettingsTab.tsx new file mode 100644 index 0000000..46061b9 --- /dev/null +++ b/src/renderer/components/settings/SyncSettingsTab.tsx @@ -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('local-folder') + const [schedule, setSchedule] = useState('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(null) + const [status, setStatus] = useState(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 => { + 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 => { + const path = await ipcCall(() => window.electronAPI.sync.pickFolder()) + if (path) setLocalFolderPath(path) + } + + const runSync = async (conflictResolution?: 'local' | 'remote' | 'cancel'): Promise => { + 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 ( +
+

{t('sync.title')}

+

+ {t('sync.desc')} +

+
+ {t('sync.mode')} + +
+ {mode === 'local-folder' ? ( +
+ setLocalFolderPath(e.target.value)} + placeholder={t('sync.localPathPlaceholder')} + /> + +
+ ) : ( + <> +
+ setWebdavUrl(e.target.value)} + placeholder={t('sync.webdavUrl')} + /> +
+
+ setWebdavUser(e.target.value)} + placeholder={t('sync.webdavUser')} + /> +
+ + )} +
+ {t('sync.schedule')} + +
+
+ setPassword(e.target.value)} + placeholder={t('sync.passwordPlaceholder')} + /> +
+
+ + +
+ {status?.lastSyncAt && ( +

+ {t('sync.lastSync', { + time: new Date(status.lastSyncAt).toLocaleString(), + status: status.lastSyncStatus ?? 'idle' + })} +

+ )} + {progress && ( +
+
+ {progress.message} +
+ )} + setConflictOpen(false)} + onChoose={(choice) => { + setConflictOpen(false) + void runSync(choice) + }} + /> +
+ ) +} diff --git a/src/renderer/components/settings/UpdateBanner.tsx b/src/renderer/components/settings/UpdateBanner.tsx new file mode 100644 index 0000000..51ae99d --- /dev/null +++ b/src/renderer/components/settings/UpdateBanner.tsx @@ -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(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 ( +
+ {t('update.available', { version })} + {!ready ? ( + + ) : ( + + )} +
+ ) +} diff --git a/src/renderer/stores/useSettingsStore.ts b/src/renderer/stores/useSettingsStore.ts index e173c4a..79e1da7 100644 --- a/src/renderer/stores/useSettingsStore.ts +++ b/src/renderer/stores/useSettingsStore.ts @@ -38,6 +38,8 @@ export const useSettingsStore = create((set, get) => ({ enableComplianceCheck: false, complianceWords: [] as string[], customSlashCommands: [] as { id: string; name: string; template: string }[], + mcpConnectorUrl: '', + extensionDevMode: false, loaded: false, load: async () => { const data = await ipcCall(() => window.electronAPI.settings.get()) diff --git a/src/renderer/styles/layout.css b/src/renderer/styles/layout.css index 08c21ca..8b38c57 100644 --- a/src/renderer/styles/layout.css +++ b/src/renderer/styles/layout.css @@ -2574,3 +2574,25 @@ gap: 8px; 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; +} diff --git a/src/shared/electron-api.d.ts b/src/shared/electron-api.d.ts index f47ae9d..ac2812d 100644 --- a/src/shared/electron-api.d.ts +++ b/src/shared/electron-api.d.ts @@ -64,6 +64,15 @@ import type { ExportMatrixParams } from './export-matrix' import type { TimelineEntry, TimelineConflict, TimelineUpsertManualInput } from './timeline' import type { CharacterArcNode } from './arc' 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 { window: { @@ -467,6 +476,39 @@ export interface ElectronAPI { setWords: (words: string[]) => Promise> checkText: (text: string) => Promise> } + undo: { + push: ( + bookId: string, + chapterId: string, + operation: UndoOperation + ) => Promise> + list: (bookId: string, chapterId: string) => Promise> + apply: (bookId: string, entryId: number) => Promise> + } + sync: { + status: () => Promise> + configure: (input: SyncConfigureInput) => Promise> + run: (input: SyncRunInput) => Promise> + pickFolder: () => Promise> + } + update: { + check: () => Promise> + download: () => Promise> + install: () => Promise> + } + log: { + hadCrash: () => Promise> + clearCrash: () => Promise> + sendReport: (note?: string) => Promise> + writeError: (message: string) => Promise> + } + extension: { + list: () => Promise> + validate: (manifest: unknown) => Promise> + } + mcp: { + testConnection: (url: string) => Promise> + } pack: { pickFile: ( mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import' @@ -505,6 +547,14 @@ export interface ElectronAPI { onGoalNotification: (callback: (payload: GoalNotificationPayload) => void) => () => void onImportProgress: (callback: (payload: { current: number; total: number }) => 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 { diff --git a/src/shared/extension-api.ts b/src/shared/extension-api.ts new file mode 100644 index 0000000..25c4163 --- /dev/null +++ b/src/shared/extension-api.ts @@ -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[] +} diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 79cba0a..5257f4a 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -168,5 +168,27 @@ export const IPC = { ARC_GET_FOR_CHARACTER: 'arc:getForCharacter', COMPLIANCE_GET_WORDS: 'compliance:getWords', 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 diff --git a/src/shared/sync.ts b/src/shared/sync.ts new file mode 100644 index 0000000..f1780d3 --- /dev/null +++ b/src/shared/sync.ts @@ -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' +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 8be5fc3..3af80d4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,3 +1,5 @@ +import type { SyncConfig } from './sync' + export type ThemeId = | 'default' | 'bamboo' @@ -295,6 +297,11 @@ export interface GlobalSettings { enableComplianceCheck?: boolean complianceWords?: string[] customSlashCommands?: CustomSlashCommand[] + syncConfig?: SyncConfig | null + syncPasswordVerifier?: string + extensionDevMode?: boolean + mcpConnectorUrl?: string + pendingCrashAck?: boolean } export interface CustomSlashCommand { diff --git a/src/shared/undo.ts b/src/shared/undo.ts new file mode 100644 index 0000000..f7b7523 --- /dev/null +++ b/src/shared/undo.ts @@ -0,0 +1,11 @@ +export interface UndoOperation { + type: 'doc' + content: string +} + +export interface UndoStackEntry { + id: number + chapterId: string + operation: UndoOperation + timestamp: string +} diff --git a/tests/main/log.service.test.ts b/tests/main/log.service.test.ts new file mode 100644 index 0000000..2bd776e --- /dev/null +++ b/tests/main/log.service.test.ts @@ -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) + }) +}) diff --git a/tests/main/migrate-v10.test.ts b/tests/main/migrate-v10.test.ts index 5f8764c..098f1bf 100644 --- a/tests/main/migrate-v10.test.ts +++ b/tests/main/migrate-v10.test.ts @@ -11,7 +11,7 @@ describe('migrate v10', () => { 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(10) + expect(version.v).toBe(11) const table = db .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='timeline_events'") .get() diff --git a/tests/main/migrate-v11.test.ts b/tests/main/migrate-v11.test.ts new file mode 100644 index 0000000..f55b755 --- /dev/null +++ b/tests/main/migrate-v11.test.ts @@ -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() + }) +}) diff --git a/tests/main/migrate-v2.test.ts b/tests/main/migrate-v2.test.ts index e0224e0..8824ddb 100644 --- a/tests/main/migrate-v2.test.ts +++ b/tests/main/migrate-v2.test.ts @@ -30,7 +30,7 @@ describe('migrate v2', () => { expect(tableExists(db, 'search_fts')).toBe(true) const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(10) + expect(version.v).toBe(11) }) it('migrates existing v1 database to v2', () => { @@ -47,7 +47,7 @@ describe('migrate v2', () => { expect(tableExists(db, 'outline_items')).toBe(true) const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number } - expect(version.v).toBe(10) + expect(version.v).toBe(11) const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[] const colNames = cols.map((c) => c.name) diff --git a/tests/main/migrate-v3.test.ts b/tests/main/migrate-v3.test.ts index 3025273..eb16eb4 100644 --- a/tests/main/migrate-v3.test.ts +++ b/tests/main/migrate-v3.test.ts @@ -25,7 +25,7 @@ describe('migrate v3', () => { expect(tableExists(db, 'ai_messages')).toBe(true) 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', () => { @@ -49,6 +49,6 @@ describe('migrate v3', () => { 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 } - expect(version.v).toBe(10) + expect(version.v).toBe(11) }) }) diff --git a/tests/main/migrate-v4.test.ts b/tests/main/migrate-v4.test.ts index 30a23c4..78ae1c7 100644 --- a/tests/main/migrate-v4.test.ts +++ b/tests/main/migrate-v4.test.ts @@ -23,7 +23,7 @@ describe('migrate v4', () => { migrate(db) 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_scenes')).toBe(true) @@ -48,7 +48,7 @@ describe('migrate v4', () => { migrate(db) 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) }) }) diff --git a/tests/main/migrate-v5.test.ts b/tests/main/migrate-v5.test.ts index 6547e1f..a338d0f 100644 --- a/tests/main/migrate-v5.test.ts +++ b/tests/main/migrate-v5.test.ts @@ -15,7 +15,7 @@ describe('migrate v5', () => { 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(10) + expect(version.v).toBe(11) 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 === 'mode_config_json')).toBe(true) @@ -39,6 +39,6 @@ describe('migrate v5', () => { db.prepare('INSERT INTO schema_version (version) VALUES (4)').run() migrate(db) 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) }) }) diff --git a/tests/main/migrate-v6.test.ts b/tests/main/migrate-v6.test.ts index e9da732..04a73f9 100644 --- a/tests/main/migrate-v6.test.ts +++ b/tests/main/migrate-v6.test.ts @@ -11,7 +11,7 @@ describe('migrate v6', () => { 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(10) + expect(version.v).toBe(11) const tables = db .prepare("SELECT name FROM sqlite_master WHERE type='table'") .all() as { name: string }[] diff --git a/tests/main/migrate-v7.test.ts b/tests/main/migrate-v7.test.ts index 8048416..1fad356 100644 --- a/tests/main/migrate-v7.test.ts +++ b/tests/main/migrate-v7.test.ts @@ -11,7 +11,7 @@ describe('migrate v7', () => { 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(10) + expect(version.v).toBe(11) const tables = db .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_writing_snapshots'") .get() diff --git a/tests/main/migrate-v8.test.ts b/tests/main/migrate-v8.test.ts index c2cedf8..392e076 100644 --- a/tests/main/migrate-v8.test.ts +++ b/tests/main/migrate-v8.test.ts @@ -11,7 +11,7 @@ describe('migrate v8', () => { 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(10) + expect(version.v).toBe(11) const table = db .prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name='knowledge_injection_logs'" diff --git a/tests/main/migrate-v9.test.ts b/tests/main/migrate-v9.test.ts index 5277537..83eedfa 100644 --- a/tests/main/migrate-v9.test.ts +++ b/tests/main/migrate-v9.test.ts @@ -11,7 +11,7 @@ describe('migrate v9', () => { 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(10) + expect(version.v).toBe(11) const table = db .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_tags'") .get() diff --git a/tests/main/sync.service.test.ts b/tests/main/sync.service.test.ts new file mode 100644 index 0000000..17f5531 --- /dev/null +++ b/tests/main/sync.service.test.ts @@ -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 }) + } + }) +}) diff --git a/tests/main/undo-stack.test.ts b/tests/main/undo-stack.test.ts new file mode 100644 index 0000000..7c0cdd8 --- /dev/null +++ b/tests/main/undo-stack.test.ts @@ -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: '

旧内容

' }) + svc.push(chapterId, { type: 'doc', content: '

新内容

' }) + const list = svc.list(chapterId) + expect(list).toHaveLength(2) + expect(list[0]?.operation.content).toContain('新内容') + }) +}) diff --git a/tests/main/update.service.test.ts b/tests/main/update.service.test.ts new file mode 100644 index 0000000..6974691 --- /dev/null +++ b/tests/main/update.service.test.ts @@ -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') + }) +})