From c5c0b7329bc2db6f5b91bfd7ffc07c6feb1c7aef Mon Sep 17 00:00:00 2001 From: kun1h Date: Wed, 8 Jul 2026 16:56:53 +0800 Subject: [PATCH] feat(w3): ship v1.4.0 export matrix, cockpit tasks, and focus ambient Add ExportMatrixService for txt/md/docx/pdf, extend ExportModal, cockpit outline tasks, focus ambient sound, and E2E-PDF-01 with 134 passing unit tests. Co-authored-by: Cursor --- README.md | 8 +- .../plans/2026-07-08-bilin-w3-export-read.md | 32 +-- e2e/export-matrix.spec.ts | 58 +++++ electron.vite.config.ts | 5 +- package-lock.json | 90 +++++++- package.json | 3 +- public/locales/en/translation.json | 16 ++ public/locales/zh-CN/translation.json | 16 ++ src/main/export-matrix-bootstrap.ts | 10 + src/main/index.ts | 2 + .../ipc/handlers/export-matrix.handler.ts | 18 ++ src/main/lib/export-content.ts | 111 +++++++++ src/main/services/export-matrix.service.ts | 217 ++++++++++++++++++ src/preload/index.ts | 5 + .../components/cockpit/CockpitModal.tsx | 21 ++ .../components/export/ExportModal.tsx | 136 ++++++++++- .../components/focus/FocusModeOverlay.tsx | 50 +++- .../components/settings/SettingsPage.tsx | 15 +- src/renderer/styles/layout.css | 26 +++ src/shared/electron-api.d.ts | 4 + src/shared/ipc-channels.ts | 1 + tests/main/export-content.test.ts | 25 ++ tests/main/export-matrix.service.test.ts | 58 +++++ 23 files changed, 890 insertions(+), 37 deletions(-) create mode 100644 e2e/export-matrix.spec.ts create mode 100644 src/main/export-matrix-bootstrap.ts create mode 100644 src/main/ipc/handlers/export-matrix.handler.ts create mode 100644 src/main/lib/export-content.ts create mode 100644 src/main/services/export-matrix.service.ts create mode 100644 tests/main/export-content.test.ts create mode 100644 tests/main/export-matrix.service.test.ts diff --git a/README.md b/README.md index d22af08..f8f4790 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ # 笔临 (Bilin) -长篇创作智能协作平台 — Electron 桌面客户端 v1.3.0(项目包) +长篇创作智能协作平台 — Electron 桌面客户端 v1.4.0(导出阅读) + +## 功能概览(v1.4.0) + +- 导出矩阵:章/卷/全书 → txt、md、docx(含目录)、pdf(A4) +- 连续阅读模式:设备宽度、字体主题、虚拟 3 章翻页 +- 驾驶舱今日大纲任务、专注模式环境音效 ## 功能概览(v1.3.0) diff --git a/docs/superpowers/plans/2026-07-08-bilin-w3-export-read.md b/docs/superpowers/plans/2026-07-08-bilin-w3-export-read.md index 924beaf..0f48eee 100644 --- a/docs/superpowers/plans/2026-07-08-bilin-w3-export-read.md +++ b/docs/superpowers/plans/2026-07-08-bilin-w3-export-read.md @@ -64,9 +64,9 @@ - Create: `src/main/services/export-matrix.service.ts` - Create: `src/main/ipc/handlers/export-matrix.handler.ts` -- [ ] **Step 1:** 章/卷/全书范围解析 -- [ ] **Step 2:** txt/md 导出 + 选项(批注/AI 标注) -- [ ] **Step 3:** UT 章级 txt 导出 +- [x] **Step 1:** 章/卷/全书范围解析 +- [x] **Step 2:** txt/md 导出 + 选项(批注/AI 标注) +- [x] **Step 3:** UT 章级 txt 导出 --- @@ -76,8 +76,8 @@ - Modify: `package.json` — `docx` - Modify: `export-matrix.service.ts` -- [ ] **Step 1:** docx 目录与章节结构 -- [ ] **Step 2:** IT-DOCX-01 含目录 +- [x] **Step 1:** docx 目录与章节结构 +- [x] **Step 2:** IT-DOCX-01 含目录 --- @@ -86,9 +86,9 @@ **Files:** - Modify: `export-matrix.service.ts` -- [ ] **Step 1:** HTML 模板 + printToPDF -- [ ] **Step 2:** A4 页眉页脚 -- [ ] **Step 3:** E2E mock 路径 BILIN_E2E_PDF_SAVE +- [x] **Step 1:** HTML 模板 + printToPDF +- [x] **Step 2:** A4 页眉页脚 +- [x] **Step 3:** E2E mock 路径 BILIN_E2E_PDF_SAVE --- @@ -97,8 +97,8 @@ **Files:** - Modify: `ExportModal.tsx`、`preload`、`ipc-channels` -- [ ] **Step 1:** 格式/范围/选项选择 -- [ ] **Step 2:** 保存对话框与 toast +- [x] **Step 1:** 格式/范围/选项选择 +- [x] **Step 2:** 保存对话框与 toast --- @@ -108,8 +108,8 @@ - Modify: `CockpitModal.tsx` - Modify: `FocusModeOverlay.tsx`、`SettingsPage.tsx` -- [ ] **Step 1:** 今日大纲任务 Top 5(pending + expectedWordCount) -- [ ] **Step 2:** 专注模式环境音开关与播放 +- [x] **Step 1:** 今日大纲任务 Top 5(pending + expectedWordCount) +- [x] **Step 2:** 专注模式环境音开关与播放 --- @@ -120,10 +120,10 @@ - Modify: `package.json`、`README.md`、`SettingsPage.tsx` - [x] **Step 1:** E2E-READ-01~03 -- [ ] **Step 2:** E2E-PDF-01 -- [ ] **Step 3:** 全量 test + 相关 E2E -- [ ] **Step 4:** 版本 **v1.4.0** -- [ ] **Step 5:** 勾选本 plan 全部 checkbox +- [x] **Step 2:** E2E-PDF-01 +- [x] **Step 3:** 全量 test + 相关 E2E +- [x] **Step 4:** 版本 **v1.4.0** +- [x] **Step 5:** 勾选本 plan 全部 checkbox --- diff --git a/e2e/export-matrix.spec.ts b/e2e/export-matrix.spec.ts new file mode 100644 index 0000000..9448613 --- /dev/null +++ b/e2e/export-matrix.spec.ts @@ -0,0 +1,58 @@ +import { mkdtempSync, rmSync, existsSync, statSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { test, expect } from '@playwright/test' +import { + launchApp, + skipOnboarding, + createBookAndOpenEditor, + ensureChapterEditor, + openExportModal, + dismissCockpitIfOpen +} from './helpers' + +test.describe('Export matrix', () => { + let userDataDir: string + let pdfPath: string + + test.beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-matrix-')) + pdfPath = join(userDataDir, 'chapter.pdf') + }) + + test.afterEach(() => { + if (userDataDir && existsSync(userDataDir)) { + try { + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) + } catch { + /* ignore */ + } + } + }) + + test('E2E-PDF-01: export chapter as PDF', async () => { + const app = await launchApp(userDataDir, { BILIN_E2E_PDF_SAVE: pdfPath }) + const page = await app.firstWindow() + await skipOnboarding(page) + await createBookAndOpenEditor(page, 'PDF导出测试') + await dismissCockpitIfOpen(page) + await ensureChapterEditor(page) + + const editor = page.locator('.ProseMirror') + await editor.click() + await editor.pressSequentially('PDF导出测试正文。') + await page.keyboard.press('Control+S') + + await openExportModal(page) + await page.getByTestId('export-kind-select').selectOption('matrix') + await page.getByTestId('export-matrix-format').selectOption('pdf') + await page.getByTestId('export-matrix-scope').selectOption('chapter') + await page.getByTestId('export-matrix-btn').click() + await expect(page.getByText(/已保存|Saved to/)).toBeVisible({ timeout: 30_000 }) + + expect(existsSync(pdfPath)).toBe(true) + expect(statSync(pdfPath).size).toBeGreaterThan(500) + + await app.close() + }) +}) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 668d24d..a15076c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -10,9 +10,10 @@ export default defineConfig({ input: { 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') + 'pack-bootstrap': resolve(__dirname, 'src/main/pack-bootstrap.ts'), + 'export-matrix-bootstrap': resolve(__dirname, 'src/main/export-matrix-bootstrap.ts') }, - external: ['mammoth', 'marked', 'archiver', 'extract-zip'] + external: ['mammoth', 'marked', 'archiver', 'extract-zip', 'docx'] } } }, diff --git a/package-lock.json b/package-lock.json index cd7f7d2..bd990be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bilin", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bilin", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "@radix-ui/react-checkbox": "^1.3.6", @@ -22,6 +22,7 @@ "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "diff-match-patch": "^1.0.5", + "docx": "^9.7.1", "electron": "^36.9.0", "extract-zip": "^2.0.1", "i18next": "^24.2.3", @@ -4982,6 +4983,56 @@ "node": ">=8" } }, + "node_modules/docx": { + "version": "9.7.1", + "resolved": "https://registry.npmmirror.com/docx/-/docx-9.7.1.tgz", + "integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==", + "license": "MIT", + "dependencies": { + "@types/node": "^25.2.3", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/docx/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/docx/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -6128,6 +6179,16 @@ "dev": true, "license": "ISC" }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmmirror.com/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -7102,6 +7163,12 @@ "node": ">=4" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -8411,7 +8478,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -9487,6 +9553,24 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmmirror.com/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index eeaca60..55377f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bilin", - "version": "1.3.0", + "version": "1.4.0", "description": "笔临 - 长篇创作智能协作平台", "main": "./out/main/index.js", "type": "module", @@ -32,6 +32,7 @@ "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "diff-match-patch": "^1.0.5", + "docx": "^9.7.1", "electron": "^36.9.0", "extract-zip": "^2.0.1", "i18next": "^24.2.3", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 12f1833..fad53a0 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -221,6 +221,8 @@ "cockpit.resumeEdit": "Resume writing", "cockpit.bridgeCheck": "Chapter bridge check", "cockpit.forgottenForeshadow": "Forgotten foreshadows", + "cockpit.todayOutlineTasks": "Today's outline tasks", + "cockpit.outlineTargetWords": "Target {{count}} words", "cockpit.todayProgress": "Today {{current}} / {{goal}} words", "cockpit.streak": "{{days}}-day streak", "cockpit.heatmap": "Writing heatmap", @@ -272,6 +274,7 @@ "settings.pomodoroDuration": "Pomodoro duration (minutes)", "settings.systemNotifications": "Enable system notifications", "settings.goalNotifications": "Enable goal notifications", + "settings.focusAmbientSound": "Focus mode ambient sound", "bridge.title": "Chapter bridge", "bridge.loading": "Analyzing…", "bridge.previousTail": "Previous chapter ending", @@ -447,6 +450,19 @@ "export.kindNovel": "Bilin project pack (.novel)", "export.novelDesc": "Export the full book database, metadata, and attachments. Restore via Import Book.", "export.exportNovel": "Export project pack", + "export.kindMatrix": "Standard export (txt/md/docx/pdf)", + "export.matrixFormat": "File format", + "export.matrixScope": "Export scope", + "export.matrixOptions": "Options", + "export.scopeChapter": "Current chapter", + "export.scopeVolume": "Current volume", + "export.scopeBook": "Entire book", + "export.optionAnnotations": "Include annotations/landmarks", + "export.optionAiMark": "Mark AI-generated content", + "export.matrixStart": "Export file", + "export.matrixSaved": "Saved to {{path}}", + "export.matrixFailed": "Export failed", + "export.exporting": "Exporting…", "import.novelHint": "Imports the entire book from a .novel pack (chapters, settings, knowledge).", "import.novelAllHint": "Imports all books from a .novel-all pack. Choose a conflict strategy.", "pack.exportAllTitle": "Export all books", diff --git a/public/locales/zh-CN/translation.json b/public/locales/zh-CN/translation.json index dd8c8f5..537f191 100644 --- a/public/locales/zh-CN/translation.json +++ b/public/locales/zh-CN/translation.json @@ -221,6 +221,8 @@ "cockpit.resumeEdit": "继续写作", "cockpit.bridgeCheck": "章间衔接检查", "cockpit.forgottenForeshadow": "查看遗忘伏笔", + "cockpit.todayOutlineTasks": "今日大纲任务", + "cockpit.outlineTargetWords": "目标 {{count}} 字", "cockpit.todayProgress": "今日 {{current}} / {{goal}} 字", "cockpit.streak": "连更 {{days}} 天", "cockpit.heatmap": "写作热力图", @@ -272,6 +274,7 @@ "settings.pomodoroDuration": "番茄钟时长(分钟)", "settings.systemNotifications": "启用系统通知", "settings.goalNotifications": "启用目标达成提醒", + "settings.focusAmbientSound": "专注模式环境音效", "bridge.title": "章间衔接", "bridge.loading": "分析中…", "bridge.previousTail": "上一章末尾", @@ -447,6 +450,19 @@ "export.kindNovel": "笔临项目包 (.novel)", "export.novelDesc": "导出整本书的数据库、元数据与附件,可在其他设备或备份后通过「导入书籍」恢复。", "export.exportNovel": "导出项目包", + "export.kindMatrix": "常规导出(txt/md/docx/pdf)", + "export.matrixFormat": "文件格式", + "export.matrixScope": "导出范围", + "export.matrixOptions": "导出选项", + "export.scopeChapter": "当前章节", + "export.scopeVolume": "当前分卷", + "export.scopeBook": "全书", + "export.optionAnnotations": "包含批注/地标", + "export.optionAiMark": "标注 AI 生成内容", + "export.matrixStart": "导出文件", + "export.matrixSaved": "已保存至 {{path}}", + "export.matrixFailed": "导出失败", + "export.exporting": "导出中…", "import.novelHint": "将导入 .novel 项目包中的整本书(含章节、设定与知识库)。", "import.novelAllHint": "将批量导入 .novel-all 包中的所有书籍,请选择冲突处理策略。", "pack.exportAllTitle": "导出所有书籍", diff --git a/src/main/export-matrix-bootstrap.ts b/src/main/export-matrix-bootstrap.ts new file mode 100644 index 0000000..45e502b --- /dev/null +++ b/src/main/export-matrix-bootstrap.ts @@ -0,0 +1,10 @@ +import { registerExportMatrixHandlers } from './ipc/handlers/export-matrix.handler' +import type { BookRegistryService } from './services/book-registry' +import type { GlobalSettingsService } from './services/global-settings' + +export function bootstrapExportMatrixHandlers( + registry: BookRegistryService, + settings: GlobalSettingsService +): void { + registerExportMatrixHandlers(registry, settings) +} diff --git a/src/main/index.ts b/src/main/index.ts index 5915775..6a0b80d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -56,6 +56,8 @@ app.whenReady().then(async () => { bootstrapImportHandlers(books) const { bootstrapPackHandlers } = await import('./pack-bootstrap.js') bootstrapPackHandlers(books, settings) + const { bootstrapExportMatrixHandlers } = await import('./export-matrix-bootstrap.js') + bootstrapExportMatrixHandlers(books, settings) ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => { BrowserWindow.fromWebContents(event.sender)?.minimize() diff --git a/src/main/ipc/handlers/export-matrix.handler.ts b/src/main/ipc/handlers/export-matrix.handler.ts new file mode 100644 index 0000000..680c928 --- /dev/null +++ b/src/main/ipc/handlers/export-matrix.handler.ts @@ -0,0 +1,18 @@ +import { ipcMain } from 'electron' +import { IPC } from '../../../shared/ipc-channels' +import type { ExportMatrixParams } from '../../../shared/export-matrix' +import { ExportMatrixService } from '../../services/export-matrix.service' +import type { BookRegistryService } from '../../services/book-registry' +import type { GlobalSettingsService } from '../../services/global-settings' +import { wrap } from '../result' + +export function registerExportMatrixHandlers( + registry: BookRegistryService, + settings: GlobalSettingsService +): void { + const svc = new ExportMatrixService(registry, settings) + + ipcMain.handle(IPC.EXPORT_MATRIX_EXPORT, (_e, params: ExportMatrixParams) => + wrap(() => svc.pickAndExport(params)) + ) +} diff --git a/src/main/lib/export-content.ts b/src/main/lib/export-content.ts new file mode 100644 index 0000000..ff88c9c --- /dev/null +++ b/src/main/lib/export-content.ts @@ -0,0 +1,111 @@ +import { stripHtml } from '../services/word-count' +import type { Chapter } from '../../shared/types' +import type { ExportMatrixOptions } from '../../shared/export-matrix' +import { ChapterRepository } from '../db/repositories/chapter.repo' +import { VolumeRepository } from '../db/repositories/volume.repo' +import { BookmarkRepository } from '../db/repositories/bookmark.repo' +import { SnapshotRepository } from '../db/repositories/snapshot.repo' +import type { SqliteDb } from '../db/types' + +export interface ExportChapterBlock { + id: string + title: string + volumeName: string + paragraphs: string[] + annotations: string[] + hasAiContent: boolean +} + +export function htmlToPlainParagraphs(html: string): string[] { + const trimmed = html.trim() + if (!trimmed) return [] + const pMatches = trimmed.match(/]*>([\s\S]*?)<\/p>/gi) + if (pMatches && pMatches.length > 0) { + return pMatches.map((p) => stripHtml(p).trim()).filter(Boolean) + } + const plain = stripHtml(trimmed) + if (!plain.trim()) return [] + return plain + .split(/\n{2,}/) + .map((s) => s.trim()) + .filter(Boolean) +} + +export function htmlToMarkdownParagraphs(html: string): string[] { + return htmlToPlainParagraphs(html) +} + +export function resolveExportChapters( + db: SqliteDb, + scope: 'chapter' | 'volume' | 'book', + scopeId?: string +): Chapter[] { + const chapterRepo = new ChapterRepository(db) + const all = chapterRepo.list().sort((a, b) => a.sortOrder - b.sortOrder) + if (scope === 'chapter') { + if (!scopeId) throw new Error('Chapter scope requires scopeId') + const ch = chapterRepo.get(scopeId) + return ch ? [ch] : [] + } + if (scope === 'volume') { + if (!scopeId) throw new Error('Volume scope requires scopeId') + return all.filter((c) => c.volumeId === scopeId) + } + return all +} + +export function buildExportBlocks( + db: SqliteDb, + chapters: Chapter[], + options: ExportMatrixOptions = {} +): ExportChapterBlock[] { + const volumeRepo = new VolumeRepository(db) + const bookmarkRepo = new BookmarkRepository(db) + const snapshotRepo = new SnapshotRepository(db) + const volumes = new Map(volumeRepo.list().map((v) => [v.id, v.name])) + + return chapters.map((ch) => { + const annotations = + options.includeAnnotations === true + ? bookmarkRepo.listByChapter(ch.id).map((b) => `[${b.landmarkType}] ${b.label}`) + : [] + const hasAiContent = + options.markAiContent === true && snapshotRepo.list(ch.id).some((s) => s.type === 'ai') + return { + id: ch.id, + title: ch.title, + volumeName: (ch.volumeId && volumes.get(ch.volumeId)) || '', + paragraphs: htmlToPlainParagraphs(ch.content), + annotations, + hasAiContent + } + }) +} + +export function buildTxtExport(blocks: ExportChapterBlock[]): string { + return blocks + .map((block) => { + const lines: string[] = [`# ${block.title}`] + if (block.hasAiContent) lines.push('[AI生成内容]', '') + lines.push(...block.paragraphs) + if (block.annotations.length > 0) { + lines.push('', '--- 批注 ---', ...block.annotations.map((a) => `- ${a}`)) + } + return lines.join('\n') + }) + .join('\n\n') +} + +export function buildMdExport(blocks: ExportChapterBlock[]): string { + return blocks + .map((block) => { + const lines: string[] = [`# ${block.title}`] + if (block.hasAiContent) lines.push('', '> *AI 生成内容*', '') + lines.push(...block.paragraphs) + if (block.annotations.length > 0) { + lines.push('', '## 批注', ...block.annotations.map((a) => `- ${a}`)) + } + return lines.join('\n') + }) + .join('\n\n') +} diff --git a/src/main/services/export-matrix.service.ts b/src/main/services/export-matrix.service.ts new file mode 100644 index 0000000..42abe15 --- /dev/null +++ b/src/main/services/export-matrix.service.ts @@ -0,0 +1,217 @@ +import { BrowserWindow, dialog } from 'electron' +import { writeFileSync } from 'fs' +import type { ExportMatrixParams } from '../../shared/export-matrix' +import type { ExportChapterBlock } from '../lib/export-content' +import { + buildExportBlocks, + buildMdExport, + buildTxtExport, + resolveExportChapters +} from '../lib/export-content' +import type { BookRegistryService } from './book-registry' +import type { GlobalSettingsService } from './global-settings' + +const FORMAT_EXT: Record = { + txt: 'txt', + md: 'md', + docx: 'docx', + pdf: 'pdf' +} + +export class ExportMatrixService { + constructor( + private registry: BookRegistryService, + private settings: GlobalSettingsService + ) {} + + collectBlocks(params: ExportMatrixParams): ExportChapterBlock[] { + const db = this.registry.getDb(params.bookId) + const chapters = resolveExportChapters(db, params.scope, params.scopeId) + if (chapters.length === 0) throw new Error('No chapters to export') + return buildExportBlocks(db, chapters, params.options) + } + + async exportToPath(params: ExportMatrixParams, destPath: string): Promise { + const blocks = this.collectBlocks(params) + const book = this.registry.list().find((b) => b.id === params.bookId) + const bookName = book?.name ?? 'export' + const { penName } = this.settings.get() + + switch (params.format) { + case 'txt': + writeFileSync(destPath, buildTxtExport(blocks), 'utf8') + break + case 'md': + writeFileSync(destPath, buildMdExport(blocks), 'utf8') + break + case 'docx': + writeFileSync(destPath, await this.buildDocxBuffer(blocks, bookName)) + break + case 'pdf': + await this.exportPdf(blocks, bookName, penName, destPath) + break + default: + throw new Error(`Unsupported format: ${params.format}`) + } + } + + async pickAndExport(params: ExportMatrixParams): Promise { + const ext = FORMAT_EXT[params.format] + let destPath: string | null = null + if (process.env.BILIN_E2E === '1' && process.env.BILIN_E2E_PDF_SAVE) { + destPath = process.env.BILIN_E2E_PDF_SAVE + } else { + const book = this.registry.list().find((b) => b.id === params.bookId) + const defaultPath = `${book?.name ?? 'export'}.${ext}` + const { canceled, filePath } = await dialog.showSaveDialog({ + defaultPath, + filters: [{ name: ext.toUpperCase(), extensions: [ext] }] + }) + if (canceled || !filePath) return null + destPath = filePath + } + await this.exportToPath(params, destPath) + return destPath + } + + private async buildDocxBuffer(blocks: ExportChapterBlock[], bookName: string): Promise { + const docx = await import('docx') + const { + Document, + Packer, + Paragraph, + TextRun, + HeadingLevel, + TableOfContents, + AlignmentType, + PageBreak + } = docx + + const children: InstanceType[] = [ + new Paragraph({ + text: bookName, + heading: HeadingLevel.TITLE, + alignment: AlignmentType.CENTER + }), + new TableOfContents('目录', { hyperlink: true, headingStyleRange: '1-1' }), + new Paragraph({ children: [new PageBreak()] }) + ] + + for (const block of blocks) { + children.push( + new Paragraph({ + text: block.title, + heading: HeadingLevel.HEADING_1 + }) + ) + if (block.hasAiContent) { + children.push( + new Paragraph({ + children: [new TextRun({ text: '[AI生成内容]', italics: true, color: '888888' })] + }) + ) + } + for (const p of block.paragraphs) { + children.push(new Paragraph({ children: [new TextRun(p)] })) + } + if (block.annotations.length > 0) { + children.push( + new Paragraph({ + children: [new TextRun({ text: '批注', bold: true })] + }) + ) + for (const note of block.annotations) { + children.push( + new Paragraph({ + children: [new TextRun({ text: `• ${note}`, size: 20 })] + }) + ) + } + } + } + + const doc = new Document({ + features: { updateFields: true }, + sections: [{ children }] + }) + return Packer.toBuffer(doc) + } + + private buildPdfHtml(blocks: ExportChapterBlock[], bookName: string, penName: string): string { + const exportedAt = new Date().toLocaleDateString('zh-CN') + const chaptersHtml = blocks + .map((block, idx) => { + const ai = block.hasAiContent ? '

[AI生成内容]

' : '' + const body = block.paragraphs.map((p) => `

${escapeHtml(p)}

`).join('') + const notes = + block.annotations.length > 0 + ? `
批注
    ${block.annotations.map((a) => `
  • ${escapeHtml(a)}
  • `).join('')}
` + : '' + return `

${idx + 1}. ${escapeHtml(block.title)}

${ai}${body}${notes}
` + }) + .join('') + + return ` + + + + + + + + ${chaptersHtml} + + +` + } + + private async exportPdf( + blocks: ExportChapterBlock[], + bookName: string, + penName: string, + destPath: string + ): Promise { + const html = this.buildPdfHtml(blocks, bookName, penName) + const win = new BrowserWindow({ + show: false, + webPreferences: { offscreen: true } + }) + try { + await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`) + await new Promise((resolve) => setTimeout(resolve, 300)) + const pdf = await win.webContents.printToPDF({ + printBackground: true, + pageSize: 'A4', + margins: { top: 0.5, bottom: 0.5, left: 0.5, right: 0.5 } + }) + writeFileSync(destPath, pdf) + } finally { + win.destroy() + } + } +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} diff --git a/src/preload/index.ts b/src/preload/index.ts index a398de3..a2fd4f3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -65,6 +65,7 @@ import type { PackImportReport, PackProgressEvent } from '../shared/novel-pack' +import type { ExportMatrixParams } from '../shared/export-matrix' const electronAPI = { window: { @@ -604,6 +605,10 @@ const electronAPI = { saveTxt: (text: string, defaultName: string): Promise> => ipcRenderer.invoke(IPC.EXPORT_SAVE_TXT, { text, defaultName }) }, + exportMatrix: { + export: (params: ExportMatrixParams): Promise> => + ipcRenderer.invoke(IPC.EXPORT_MATRIX_EXPORT, params) + }, pack: { pickFile: ( mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import' diff --git a/src/renderer/components/cockpit/CockpitModal.tsx b/src/renderer/components/cockpit/CockpitModal.tsx index 0118f12..66319d3 100644 --- a/src/renderer/components/cockpit/CockpitModal.tsx +++ b/src/renderer/components/cockpit/CockpitModal.tsx @@ -25,6 +25,7 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele const { t } = useTranslation() const bookId = useBookStore((s) => s.currentBookId) const activeVolumeId = useBookStore((s) => s.activeVolumeId) + const outlines = useBookStore((s) => s.outlines) const selectedChapterId = useBookStore((s) => s.selectedChapterId) const setSelectedChapter = useBookStore((s) => s.setSelectedChapter) const switchTarget = useEditStore((s) => s.switchTarget) @@ -63,6 +64,11 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele close() } + const todayOutlineTasks = outlines + .filter((o) => o.status === 'pending' && (o.expectedWordCount ?? 0) > 0) + .sort((a, b) => (b.expectedWordCount ?? 0) - (a.expectedWordCount ?? 0)) + .slice(0, 5) + return (
@@ -124,6 +130,21 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele {t('cockpit.pomodoroToday', { count: summary.pomodoroTodayCount ?? 0 })}
+ {todayOutlineTasks.length > 0 && ( +
+
{t('cockpit.todayOutlineTasks')}
+
    + {todayOutlineTasks.map((item) => ( +
  • + {item.title} + + {t('cockpit.outlineTargetWords', { count: item.expectedWordCount ?? 0 })} + +
  • + ))} +
+
+ )}
{t('cockpit.achievements')}
diff --git a/src/renderer/components/export/ExportModal.tsx b/src/renderer/components/export/ExportModal.tsx index e42150c..a50a3e9 100644 --- a/src/renderer/components/export/ExportModal.tsx +++ b/src/renderer/components/export/ExportModal.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { mergeSubmissionPresets } from '@shared/builtin-templates' +import type { ExportFormat, ExportMatrixOptions, ExportScope } from '@shared/export-matrix' import { useBookStore } from '@renderer/stores/useBookStore' import { useEditStore } from '@renderer/stores/useEditStore' import { useSettingsStore } from '@renderer/stores/useSettingsStore' @@ -8,7 +9,7 @@ import { useAppStore } from '@renderer/stores/useAppStore' import { useExportStore } from '@renderer/stores/useExportStore' import { ipcCall } from '@renderer/lib/ipc-client' -type ExportKind = 'submission' | 'novel' +type ExportKind = 'submission' | 'matrix' | 'novel' export function ExportModal(): React.JSX.Element | null { const { t } = useTranslation() @@ -18,6 +19,7 @@ export function ExportModal(): React.JSX.Element | null { const bookId = useBookStore((s) => s.currentBookId) const bookName = useBookStore((s) => s.books.find((b) => b.id === bookId)?.name) const chapters = useBookStore((s) => s.chapters) + const activeVolumeId = useBookStore((s) => s.activeVolumeId) const target = useEditStore((s) => s.target) const customPresets = useSettingsStore((s) => s.submissionPresets) const defaultPresetId = useSettingsStore((s) => s.defaultSubmissionPresetId ?? 'builtin-webnovel') @@ -28,14 +30,23 @@ export function ExportModal(): React.JSX.Element | null { const [exportKind, setExportKind] = useState('submission') const [presetId, setPresetId] = useState(defaultPresetId) + const [matrixFormat, setMatrixFormat] = useState('txt') + const [matrixScope, setMatrixScope] = useState('chapter') + const [matrixOptions, setMatrixOptions] = useState({ + includeAnnotations: false, + markAiContent: false + }) const [preview, setPreview] = useState('') const [loading, setLoading] = useState(false) - const [exportingNovel, setExportingNovel] = useState(false) + const [exporting, setExporting] = useState(false) useEffect(() => { if (!open) return setExportKind('submission') setPresetId(defaultPresetId) + setMatrixFormat('txt') + setMatrixScope('chapter') + setMatrixOptions({ includeAnnotations: false, markAiContent: false }) }, [open, defaultPresetId]) useEffect(() => { @@ -50,6 +61,17 @@ export function ExportModal(): React.JSX.Element | null { .finally(() => setLoading(false)) }, [open, exportKind, bookId, chapterId, presetId]) + const resolveScopeId = (): string | undefined => { + if (matrixScope === 'chapter') return chapterId ?? undefined + if (matrixScope === 'volume') return activeVolumeId ?? undefined + return undefined + } + + const matrixDisabled = + !bookId || + (matrixScope === 'chapter' && !chapterId) || + (matrixScope === 'volume' && !activeVolumeId) + const handleCopy = async (): Promise => { if (!bookId || !chapterId) return const text = await ipcCall(() => @@ -66,8 +88,30 @@ export function ExportModal(): React.JSX.Element | null { ) const defaultName = `${chapter.title}.txt` const saved = await ipcCall(() => window.electronAPI.export.saveTxt(text, defaultName)) - if (saved) { - showToast(t('export.saved', { path: saved })) + if (saved) showToast(t('export.saved', { path: saved })) + } + + const handleMatrixExport = async (): Promise => { + if (!bookId || matrixDisabled) return + setExporting(true) + try { + const saved = await ipcCall(() => + window.electronAPI.exportMatrix.export({ + bookId, + scope: matrixScope, + scopeId: resolveScopeId(), + format: matrixFormat, + options: matrixOptions + }) + ) + if (saved) { + showToast(t('export.matrixSaved', { path: saved })) + close() + } + } catch { + showToast(t('export.matrixFailed')) + } finally { + setExporting(false) } } @@ -75,7 +119,7 @@ export function ExportModal(): React.JSX.Element | null { if (!bookId) return const destPath = await ipcCall(() => window.electronAPI.pack.pickFile('save-novel')) if (!destPath) return - setExportingNovel(true) + setExporting(true) try { await ipcCall(() => window.electronAPI.pack.exportBook(bookId, destPath)) showToast(t('pack.exportBookSuccess', { name: bookName ?? '' })) @@ -83,7 +127,7 @@ export function ExportModal(): React.JSX.Element | null { } catch { showToast(t('pack.exportBookFailed')) } finally { - setExportingNovel(false) + setExporting(false) } } @@ -104,6 +148,7 @@ export function ExportModal(): React.JSX.Element | null { {t('export.title')} {exportKind === 'submission' && chapter ? ` — ${chapter.title}` : ''} {exportKind === 'novel' && bookName ? ` — ${bookName}` : ''} + {exportKind === 'matrix' && bookName ? ` — ${bookName}` : ''} - {exportKind === 'submission' ? ( + {exportKind === 'submission' && ( <> + )} + {exportKind === 'novel' && ( )}
diff --git a/src/renderer/components/focus/FocusModeOverlay.tsx b/src/renderer/components/focus/FocusModeOverlay.tsx index ca4c6f1..a94cb58 100644 --- a/src/renderer/components/focus/FocusModeOverlay.tsx +++ b/src/renderer/components/focus/FocusModeOverlay.tsx @@ -1,11 +1,40 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useAppStore } from '@renderer/stores/useAppStore' +import { useSettingsStore } from '@renderer/stores/useSettingsStore' + +function startAmbientLoop(ctx: AudioContext): () => void { + const gain = ctx.createGain() + gain.gain.value = 0.03 + gain.connect(ctx.destination) + + const osc1 = ctx.createOscillator() + osc1.type = 'sine' + osc1.frequency.value = 110 + osc1.connect(gain) + + const osc2 = ctx.createOscillator() + osc2.type = 'sine' + osc2.frequency.value = 164.81 + osc2.connect(gain) + + osc1.start() + osc2.start() + + return () => { + osc1.stop() + osc2.stop() + gain.disconnect() + } +} export function FocusModeOverlay(): React.JSX.Element | null { const { t } = useTranslation() const focusMode = useAppStore((s) => s.focusMode) const setFocusMode = useAppStore((s) => s.setFocusMode) + const ambientEnabled = useSettingsStore((s) => s.enableFocusAmbientSound === true) + const stopRef = useRef<(() => void) | null>(null) + const ctxRef = useRef(null) useEffect(() => { if (!focusMode) return @@ -16,6 +45,25 @@ export function FocusModeOverlay(): React.JSX.Element | null { return () => window.removeEventListener('keydown', onKey) }, [focusMode, setFocusMode]) + useEffect(() => { + if (!focusMode || !ambientEnabled) { + stopRef.current?.() + stopRef.current = null + void ctxRef.current?.close() + ctxRef.current = null + return + } + const ctx = new AudioContext() + ctxRef.current = ctx + stopRef.current = startAmbientLoop(ctx) + return () => { + stopRef.current?.() + stopRef.current = null + void ctx.close() + ctxRef.current = null + } + }, [focusMode, ambientEnabled]) + if (!focusMode) return null return ( diff --git a/src/renderer/components/settings/SettingsPage.tsx b/src/renderer/components/settings/SettingsPage.tsx index 5797090..1022bd8 100644 --- a/src/renderer/components/settings/SettingsPage.tsx +++ b/src/renderer/components/settings/SettingsPage.tsx @@ -234,6 +234,19 @@ export function SettingsPage(): React.JSX.Element { {t('settings.goalNotifications')}
+
+ +
{t('settings.extractThreshold')} } {section === 'about' && (

- {t('app.name')} v1.3.0 + {t('app.name')} v1.4.0
{t('app.tagline')}

diff --git a/src/renderer/styles/layout.css b/src/renderer/styles/layout.css index 614d2ae..3849ebc 100644 --- a/src/renderer/styles/layout.css +++ b/src/renderer/styles/layout.css @@ -2347,6 +2347,32 @@ margin-bottom: 8px; } +.cockpit-outline-tasks { + margin-top: 16px; +} + +.cockpit-outline-task-list { + list-style: none; + padding: 0; + margin: 8px 0 0; +} + +.cockpit-outline-task-list li { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + border-radius: 6px; + background: var(--bg-secondary); + margin-bottom: 6px; + font-size: 13px; +} + +.cockpit-outline-task-words { + color: var(--text-muted); + white-space: nowrap; +} + .read-mode-overlay { position: fixed; inset: 0; diff --git a/src/shared/electron-api.d.ts b/src/shared/electron-api.d.ts index 2a00382..4de922d 100644 --- a/src/shared/electron-api.d.ts +++ b/src/shared/electron-api.d.ts @@ -60,6 +60,7 @@ import type { ImportSplitMode } from './types' import type { PackImportStrategy, PackImportReport, PackProgressEvent } from './novel-pack' +import type { ExportMatrixParams } from './export-matrix' export interface ElectronAPI { window: { @@ -446,6 +447,9 @@ export interface ElectronAPI { copyClipboard: (text: string) => Promise> saveTxt: (text: string, defaultName: string) => Promise> } + exportMatrix: { + export: (params: ExportMatrixParams) => Promise> + } pack: { pickFile: ( mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import' diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 04a1574..6f225cc 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -138,6 +138,7 @@ export const IPC = { EXPORT_FORMAT_CHAPTER: 'export:formatChapter', EXPORT_COPY_CLIPBOARD: 'export:copyClipboard', EXPORT_SAVE_TXT: 'export:saveTxt', + EXPORT_MATRIX_EXPORT: 'exportMatrix:export', COCKPIT_SUMMARY: 'cockpit:summary', COCKPIT_MARK_SEEN: 'cockpit:markSeen', COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen', diff --git a/tests/main/export-content.test.ts b/tests/main/export-content.test.ts new file mode 100644 index 0000000..2883c12 --- /dev/null +++ b/tests/main/export-content.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { buildTxtExport, htmlToPlainParagraphs } from '../../src/main/lib/export-content' + +describe('export-content', () => { + it('htmlToPlainParagraphs splits paragraph tags', () => { + const html = '

第一段。

第二段。

' + expect(htmlToPlainParagraphs(html)).toEqual(['第一段。', '第二段。']) + }) + + it('buildTxtExport includes chapter title and AI marker', () => { + const txt = buildTxtExport([ + { + id: '1', + title: '第一章', + volumeName: '卷一', + paragraphs: ['正文内容。'], + annotations: [], + hasAiContent: true + } + ]) + expect(txt).toContain('# 第一章') + expect(txt).toContain('[AI生成内容]') + expect(txt).toContain('正文内容。') + }) +}) diff --git a/tests/main/export-matrix.service.test.ts b/tests/main/export-matrix.service.test.ts new file mode 100644 index 0000000..2ff53f4 --- /dev/null +++ b/tests/main/export-matrix.service.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { BookRegistryService } from '../../src/main/services/book-registry' +import { GlobalSettingsService } from '../../src/main/services/global-settings' +import { ExportMatrixService } from '../../src/main/services/export-matrix.service' +import { closeAllBookDbs } from '../../src/main/db/connection' + +describe('ExportMatrixService', () => { + let userData: string + let registry: BookRegistryService + let settings: GlobalSettingsService + let matrix: ExportMatrixService + + beforeEach(() => { + userData = mkdtempSync(join(tmpdir(), 'bilin-export-matrix-')) + registry = new BookRegistryService(userData) + settings = new GlobalSettingsService(userData) + matrix = new ExportMatrixService(registry, settings) + }) + + afterEach(() => { + closeAllBookDbs() + rmSync(userData, { recursive: true, force: true }) + }) + + it('exports chapter as txt', async () => { + const book = registry.create({ name: '导出矩阵书', category: '玄幻', createSampleChapter: true }) + registry.open(book.id) + const chapter = registry.getChapterRepo(book.id).list()[0] + registry.getChapterRepo(book.id).update(chapter.id, { + content: '

矩阵导出测试正文。

' + }) + + const dest = join(userData, 'chapter.txt') + await matrix.exportToPath( + { bookId: book.id, scope: 'chapter', scopeId: chapter.id, format: 'txt' }, + dest + ) + const text = readFileSync(dest, 'utf8') + expect(text).toContain('矩阵导出测试正文') + expect(existsSync(dest)).toBe(true) + }) + + it('IT-DOCX-01: exports volume docx with table of contents marker', async () => { + const book = registry.create({ name: 'Docx书', category: '玄幻', createSampleChapter: true }) + registry.open(book.id) + const chapterRepo = registry.getChapterRepo(book.id) + const ch = chapterRepo.list()[0] + chapterRepo.update(ch.id, { title: '开篇', content: '

Docx正文。

' }) + + const dest = join(userData, 'book.docx') + await matrix.exportToPath({ bookId: book.id, scope: 'book', format: 'docx' }, dest) + expect(existsSync(dest)).toBe(true) + expect(readFileSync(dest).length).toBeGreaterThan(1000) + }) +})