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 <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,12 @@
|
|||||||
# 笔临 (Bilin)
|
# 笔临 (Bilin)
|
||||||
|
|
||||||
长篇创作智能协作平台 — Electron 桌面客户端 v1.3.0(项目包)
|
长篇创作智能协作平台 — Electron 桌面客户端 v1.4.0(导出阅读)
|
||||||
|
|
||||||
|
## 功能概览(v1.4.0)
|
||||||
|
|
||||||
|
- 导出矩阵:章/卷/全书 → txt、md、docx(含目录)、pdf(A4)
|
||||||
|
- 连续阅读模式:设备宽度、字体主题、虚拟 3 章翻页
|
||||||
|
- 驾驶舱今日大纲任务、专注模式环境音效
|
||||||
|
|
||||||
## 功能概览(v1.3.0)
|
## 功能概览(v1.3.0)
|
||||||
|
|
||||||
|
|||||||
@@ -64,9 +64,9 @@
|
|||||||
- Create: `src/main/services/export-matrix.service.ts`
|
- Create: `src/main/services/export-matrix.service.ts`
|
||||||
- Create: `src/main/ipc/handlers/export-matrix.handler.ts`
|
- Create: `src/main/ipc/handlers/export-matrix.handler.ts`
|
||||||
|
|
||||||
- [ ] **Step 1:** 章/卷/全书范围解析
|
- [x] **Step 1:** 章/卷/全书范围解析
|
||||||
- [ ] **Step 2:** txt/md 导出 + 选项(批注/AI 标注)
|
- [x] **Step 2:** txt/md 导出 + 选项(批注/AI 标注)
|
||||||
- [ ] **Step 3:** UT 章级 txt 导出
|
- [x] **Step 3:** UT 章级 txt 导出
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,8 +76,8 @@
|
|||||||
- Modify: `package.json` — `docx`
|
- Modify: `package.json` — `docx`
|
||||||
- Modify: `export-matrix.service.ts`
|
- Modify: `export-matrix.service.ts`
|
||||||
|
|
||||||
- [ ] **Step 1:** docx 目录与章节结构
|
- [x] **Step 1:** docx 目录与章节结构
|
||||||
- [ ] **Step 2:** IT-DOCX-01 含目录
|
- [x] **Step 2:** IT-DOCX-01 含目录
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -86,9 +86,9 @@
|
|||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `export-matrix.service.ts`
|
- Modify: `export-matrix.service.ts`
|
||||||
|
|
||||||
- [ ] **Step 1:** HTML 模板 + printToPDF
|
- [x] **Step 1:** HTML 模板 + printToPDF
|
||||||
- [ ] **Step 2:** A4 页眉页脚
|
- [x] **Step 2:** A4 页眉页脚
|
||||||
- [ ] **Step 3:** E2E mock 路径 BILIN_E2E_PDF_SAVE
|
- [x] **Step 3:** E2E mock 路径 BILIN_E2E_PDF_SAVE
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -97,8 +97,8 @@
|
|||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `ExportModal.tsx`、`preload`、`ipc-channels`
|
- Modify: `ExportModal.tsx`、`preload`、`ipc-channels`
|
||||||
|
|
||||||
- [ ] **Step 1:** 格式/范围/选项选择
|
- [x] **Step 1:** 格式/范围/选项选择
|
||||||
- [ ] **Step 2:** 保存对话框与 toast
|
- [x] **Step 2:** 保存对话框与 toast
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -108,8 +108,8 @@
|
|||||||
- Modify: `CockpitModal.tsx`
|
- Modify: `CockpitModal.tsx`
|
||||||
- Modify: `FocusModeOverlay.tsx`、`SettingsPage.tsx`
|
- Modify: `FocusModeOverlay.tsx`、`SettingsPage.tsx`
|
||||||
|
|
||||||
- [ ] **Step 1:** 今日大纲任务 Top 5(pending + expectedWordCount)
|
- [x] **Step 1:** 今日大纲任务 Top 5(pending + expectedWordCount)
|
||||||
- [ ] **Step 2:** 专注模式环境音开关与播放
|
- [x] **Step 2:** 专注模式环境音开关与播放
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -120,10 +120,10 @@
|
|||||||
- Modify: `package.json`、`README.md`、`SettingsPage.tsx`
|
- Modify: `package.json`、`README.md`、`SettingsPage.tsx`
|
||||||
|
|
||||||
- [x] **Step 1:** E2E-READ-01~03
|
- [x] **Step 1:** E2E-READ-01~03
|
||||||
- [ ] **Step 2:** E2E-PDF-01
|
- [x] **Step 2:** E2E-PDF-01
|
||||||
- [ ] **Step 3:** 全量 test + 相关 E2E
|
- [x] **Step 3:** 全量 test + 相关 E2E
|
||||||
- [ ] **Step 4:** 版本 **v1.4.0**
|
- [x] **Step 4:** 版本 **v1.4.0**
|
||||||
- [ ] **Step 5:** 勾选本 plan 全部 checkbox
|
- [x] **Step 5:** 勾选本 plan 全部 checkbox
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,9 +10,10 @@ export default defineConfig({
|
|||||||
input: {
|
input: {
|
||||||
index: resolve(__dirname, 'src/main/index.ts'),
|
index: resolve(__dirname, 'src/main/index.ts'),
|
||||||
'import-bootstrap': resolve(__dirname, 'src/main/import-bootstrap.ts'),
|
'import-bootstrap': resolve(__dirname, 'src/main/import-bootstrap.ts'),
|
||||||
'pack-bootstrap': resolve(__dirname, 'src/main/pack-bootstrap.ts')
|
'pack-bootstrap': resolve(__dirname, 'src/main/pack-bootstrap.ts'),
|
||||||
|
'export-matrix-bootstrap': resolve(__dirname, 'src/main/export-matrix-bootstrap.ts')
|
||||||
},
|
},
|
||||||
external: ['mammoth', 'marked', 'archiver', 'extract-zip']
|
external: ['mammoth', 'marked', 'archiver', 'extract-zip', 'docx']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+87
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bilin",
|
"name": "bilin",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bilin",
|
"name": "bilin",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-checkbox": "^1.3.6",
|
"@radix-ui/react-checkbox": "^1.3.6",
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"cytoscape": "^3.34.0",
|
"cytoscape": "^3.34.0",
|
||||||
"cytoscape-cose-bilkent": "^4.1.0",
|
"cytoscape-cose-bilkent": "^4.1.0",
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
|
"docx": "^9.7.1",
|
||||||
"electron": "^36.9.0",
|
"electron": "^36.9.0",
|
||||||
"extract-zip": "^2.0.1",
|
"extract-zip": "^2.0.1",
|
||||||
"i18next": "^24.2.3",
|
"i18next": "^24.2.3",
|
||||||
@@ -4982,6 +4983,56 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
@@ -6128,6 +6179,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
@@ -7102,6 +7163,12 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.5",
|
"version": "10.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||||
@@ -8411,7 +8478,6 @@
|
|||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
||||||
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
|
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=11.0.0"
|
"node": ">=11.0.0"
|
||||||
@@ -9487,6 +9553,24 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/xmlbuilder": {
|
||||||
"version": "15.1.1",
|
"version": "15.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bilin",
|
"name": "bilin",
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"description": "笔临 - 长篇创作智能协作平台",
|
"description": "笔临 - 长篇创作智能协作平台",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
"cytoscape": "^3.34.0",
|
"cytoscape": "^3.34.0",
|
||||||
"cytoscape-cose-bilkent": "^4.1.0",
|
"cytoscape-cose-bilkent": "^4.1.0",
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
|
"docx": "^9.7.1",
|
||||||
"electron": "^36.9.0",
|
"electron": "^36.9.0",
|
||||||
"extract-zip": "^2.0.1",
|
"extract-zip": "^2.0.1",
|
||||||
"i18next": "^24.2.3",
|
"i18next": "^24.2.3",
|
||||||
|
|||||||
@@ -221,6 +221,8 @@
|
|||||||
"cockpit.resumeEdit": "Resume writing",
|
"cockpit.resumeEdit": "Resume writing",
|
||||||
"cockpit.bridgeCheck": "Chapter bridge check",
|
"cockpit.bridgeCheck": "Chapter bridge check",
|
||||||
"cockpit.forgottenForeshadow": "Forgotten foreshadows",
|
"cockpit.forgottenForeshadow": "Forgotten foreshadows",
|
||||||
|
"cockpit.todayOutlineTasks": "Today's outline tasks",
|
||||||
|
"cockpit.outlineTargetWords": "Target {{count}} words",
|
||||||
"cockpit.todayProgress": "Today {{current}} / {{goal}} words",
|
"cockpit.todayProgress": "Today {{current}} / {{goal}} words",
|
||||||
"cockpit.streak": "{{days}}-day streak",
|
"cockpit.streak": "{{days}}-day streak",
|
||||||
"cockpit.heatmap": "Writing heatmap",
|
"cockpit.heatmap": "Writing heatmap",
|
||||||
@@ -272,6 +274,7 @@
|
|||||||
"settings.pomodoroDuration": "Pomodoro duration (minutes)",
|
"settings.pomodoroDuration": "Pomodoro duration (minutes)",
|
||||||
"settings.systemNotifications": "Enable system notifications",
|
"settings.systemNotifications": "Enable system notifications",
|
||||||
"settings.goalNotifications": "Enable goal notifications",
|
"settings.goalNotifications": "Enable goal notifications",
|
||||||
|
"settings.focusAmbientSound": "Focus mode ambient sound",
|
||||||
"bridge.title": "Chapter bridge",
|
"bridge.title": "Chapter bridge",
|
||||||
"bridge.loading": "Analyzing…",
|
"bridge.loading": "Analyzing…",
|
||||||
"bridge.previousTail": "Previous chapter ending",
|
"bridge.previousTail": "Previous chapter ending",
|
||||||
@@ -447,6 +450,19 @@
|
|||||||
"export.kindNovel": "Bilin project pack (.novel)",
|
"export.kindNovel": "Bilin project pack (.novel)",
|
||||||
"export.novelDesc": "Export the full book database, metadata, and attachments. Restore via Import Book.",
|
"export.novelDesc": "Export the full book database, metadata, and attachments. Restore via Import Book.",
|
||||||
"export.exportNovel": "Export project pack",
|
"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.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.",
|
"import.novelAllHint": "Imports all books from a .novel-all pack. Choose a conflict strategy.",
|
||||||
"pack.exportAllTitle": "Export all books",
|
"pack.exportAllTitle": "Export all books",
|
||||||
|
|||||||
@@ -221,6 +221,8 @@
|
|||||||
"cockpit.resumeEdit": "继续写作",
|
"cockpit.resumeEdit": "继续写作",
|
||||||
"cockpit.bridgeCheck": "章间衔接检查",
|
"cockpit.bridgeCheck": "章间衔接检查",
|
||||||
"cockpit.forgottenForeshadow": "查看遗忘伏笔",
|
"cockpit.forgottenForeshadow": "查看遗忘伏笔",
|
||||||
|
"cockpit.todayOutlineTasks": "今日大纲任务",
|
||||||
|
"cockpit.outlineTargetWords": "目标 {{count}} 字",
|
||||||
"cockpit.todayProgress": "今日 {{current}} / {{goal}} 字",
|
"cockpit.todayProgress": "今日 {{current}} / {{goal}} 字",
|
||||||
"cockpit.streak": "连更 {{days}} 天",
|
"cockpit.streak": "连更 {{days}} 天",
|
||||||
"cockpit.heatmap": "写作热力图",
|
"cockpit.heatmap": "写作热力图",
|
||||||
@@ -272,6 +274,7 @@
|
|||||||
"settings.pomodoroDuration": "番茄钟时长(分钟)",
|
"settings.pomodoroDuration": "番茄钟时长(分钟)",
|
||||||
"settings.systemNotifications": "启用系统通知",
|
"settings.systemNotifications": "启用系统通知",
|
||||||
"settings.goalNotifications": "启用目标达成提醒",
|
"settings.goalNotifications": "启用目标达成提醒",
|
||||||
|
"settings.focusAmbientSound": "专注模式环境音效",
|
||||||
"bridge.title": "章间衔接",
|
"bridge.title": "章间衔接",
|
||||||
"bridge.loading": "分析中…",
|
"bridge.loading": "分析中…",
|
||||||
"bridge.previousTail": "上一章末尾",
|
"bridge.previousTail": "上一章末尾",
|
||||||
@@ -447,6 +450,19 @@
|
|||||||
"export.kindNovel": "笔临项目包 (.novel)",
|
"export.kindNovel": "笔临项目包 (.novel)",
|
||||||
"export.novelDesc": "导出整本书的数据库、元数据与附件,可在其他设备或备份后通过「导入书籍」恢复。",
|
"export.novelDesc": "导出整本书的数据库、元数据与附件,可在其他设备或备份后通过「导入书籍」恢复。",
|
||||||
"export.exportNovel": "导出项目包",
|
"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.novelHint": "将导入 .novel 项目包中的整本书(含章节、设定与知识库)。",
|
||||||
"import.novelAllHint": "将批量导入 .novel-all 包中的所有书籍,请选择冲突处理策略。",
|
"import.novelAllHint": "将批量导入 .novel-all 包中的所有书籍,请选择冲突处理策略。",
|
||||||
"pack.exportAllTitle": "导出所有书籍",
|
"pack.exportAllTitle": "导出所有书籍",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -56,6 +56,8 @@ app.whenReady().then(async () => {
|
|||||||
bootstrapImportHandlers(books)
|
bootstrapImportHandlers(books)
|
||||||
const { bootstrapPackHandlers } = await import('./pack-bootstrap.js')
|
const { bootstrapPackHandlers } = await import('./pack-bootstrap.js')
|
||||||
bootstrapPackHandlers(books, settings)
|
bootstrapPackHandlers(books, settings)
|
||||||
|
const { bootstrapExportMatrixHandlers } = await import('./export-matrix-bootstrap.js')
|
||||||
|
bootstrapExportMatrixHandlers(books, settings)
|
||||||
|
|
||||||
ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => {
|
ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => {
|
||||||
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
||||||
|
|||||||
@@ -0,0 +1,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))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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(/<p[^>]*>([\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')
|
||||||
|
}
|
||||||
@@ -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<ExportMatrixParams['format'], string> = {
|
||||||
|
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<void> {
|
||||||
|
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<string | null> {
|
||||||
|
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<Buffer> {
|
||||||
|
const docx = await import('docx')
|
||||||
|
const {
|
||||||
|
Document,
|
||||||
|
Packer,
|
||||||
|
Paragraph,
|
||||||
|
TextRun,
|
||||||
|
HeadingLevel,
|
||||||
|
TableOfContents,
|
||||||
|
AlignmentType,
|
||||||
|
PageBreak
|
||||||
|
} = docx
|
||||||
|
|
||||||
|
const children: InstanceType<typeof Paragraph>[] = [
|
||||||
|
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 ? '<p class="ai-badge">[AI生成内容]</p>' : ''
|
||||||
|
const body = block.paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`).join('')
|
||||||
|
const notes =
|
||||||
|
block.annotations.length > 0
|
||||||
|
? `<div class="annotations"><strong>批注</strong><ul>${block.annotations.map((a) => `<li>${escapeHtml(a)}</li>`).join('')}</ul></div>`
|
||||||
|
: ''
|
||||||
|
return `<section class="chapter"><h2>${idx + 1}. ${escapeHtml(block.title)}</h2>${ai}${body}${notes}</section>`
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<style>
|
||||||
|
@page { margin: 2cm 2cm 2.5cm 2cm; }
|
||||||
|
body { font-family: 'Noto Serif SC', Georgia, serif; font-size: 12pt; line-height: 1.8; color: #222; }
|
||||||
|
.print-header { font-size: 9pt; color: #666; border-bottom: 1px solid #ccc; padding-bottom: 6px; margin-bottom: 24px; display: flex; justify-content: space-between; }
|
||||||
|
.chapter { page-break-before: always; }
|
||||||
|
.chapter:first-of-type { page-break-before: auto; }
|
||||||
|
h2 { font-size: 16pt; margin: 0 0 12px; }
|
||||||
|
.ai-badge { font-size: 9pt; color: #888; }
|
||||||
|
.annotations { font-size: 9pt; color: #555; margin-top: 16px; border-top: 1px dashed #ccc; padding-top: 8px; }
|
||||||
|
.print-footer { font-size: 9pt; color: #666; margin-top: 32px; border-top: 1px solid #ccc; padding-top: 6px; display: flex; justify-content: space-between; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="print-header">
|
||||||
|
<span>${escapeHtml(bookName)}</span>
|
||||||
|
<span>笔临导出</span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
${chaptersHtml}
|
||||||
|
<div class="print-footer">
|
||||||
|
<span>${escapeHtml(penName)}</span>
|
||||||
|
<span>${exportedAt}</span>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exportPdf(
|
||||||
|
blocks: ExportChapterBlock[],
|
||||||
|
bookName: string,
|
||||||
|
penName: string,
|
||||||
|
destPath: string
|
||||||
|
): Promise<void> {
|
||||||
|
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<void>((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, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ import type {
|
|||||||
PackImportReport,
|
PackImportReport,
|
||||||
PackProgressEvent
|
PackProgressEvent
|
||||||
} from '../shared/novel-pack'
|
} from '../shared/novel-pack'
|
||||||
|
import type { ExportMatrixParams } from '../shared/export-matrix'
|
||||||
|
|
||||||
const electronAPI = {
|
const electronAPI = {
|
||||||
window: {
|
window: {
|
||||||
@@ -604,6 +605,10 @@ const electronAPI = {
|
|||||||
saveTxt: (text: string, defaultName: string): Promise<IpcResult<string | null>> =>
|
saveTxt: (text: string, defaultName: string): Promise<IpcResult<string | null>> =>
|
||||||
ipcRenderer.invoke(IPC.EXPORT_SAVE_TXT, { text, defaultName })
|
ipcRenderer.invoke(IPC.EXPORT_SAVE_TXT, { text, defaultName })
|
||||||
},
|
},
|
||||||
|
exportMatrix: {
|
||||||
|
export: (params: ExportMatrixParams): Promise<IpcResult<string | null>> =>
|
||||||
|
ipcRenderer.invoke(IPC.EXPORT_MATRIX_EXPORT, params)
|
||||||
|
},
|
||||||
pack: {
|
pack: {
|
||||||
pickFile: (
|
pickFile: (
|
||||||
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
|
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const bookId = useBookStore((s) => s.currentBookId)
|
const bookId = useBookStore((s) => s.currentBookId)
|
||||||
const activeVolumeId = useBookStore((s) => s.activeVolumeId)
|
const activeVolumeId = useBookStore((s) => s.activeVolumeId)
|
||||||
|
const outlines = useBookStore((s) => s.outlines)
|
||||||
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
|
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
|
||||||
const setSelectedChapter = useBookStore((s) => s.setSelectedChapter)
|
const setSelectedChapter = useBookStore((s) => s.setSelectedChapter)
|
||||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||||
@@ -63,6 +64,11 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele
|
|||||||
close()
|
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 (
|
return (
|
||||||
<div className="modal-overlay" data-testid="cockpit-modal">
|
<div className="modal-overlay" data-testid="cockpit-modal">
|
||||||
<div className="modal-content modal-content--wide">
|
<div className="modal-content modal-content--wide">
|
||||||
@@ -124,6 +130,21 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele
|
|||||||
{t('cockpit.pomodoroToday', { count: summary.pomodoroTodayCount ?? 0 })}
|
{t('cockpit.pomodoroToday', { count: summary.pomodoroTodayCount ?? 0 })}
|
||||||
</div>
|
</div>
|
||||||
<CockpitAnalytics analytics={analytics} />
|
<CockpitAnalytics analytics={analytics} />
|
||||||
|
{todayOutlineTasks.length > 0 && (
|
||||||
|
<div className="cockpit-outline-tasks" data-testid="cockpit-outline-tasks">
|
||||||
|
<div className="cockpit-heatmap-label">{t('cockpit.todayOutlineTasks')}</div>
|
||||||
|
<ul className="cockpit-outline-task-list">
|
||||||
|
{todayOutlineTasks.map((item) => (
|
||||||
|
<li key={item.id} data-testid={`cockpit-outline-task-${item.id}`}>
|
||||||
|
<span>{item.title}</span>
|
||||||
|
<span className="cockpit-outline-task-words">
|
||||||
|
{t('cockpit.outlineTargetWords', { count: item.expectedWordCount ?? 0 })}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="cockpit-achievements-section">
|
<div className="cockpit-achievements-section">
|
||||||
<div className="cockpit-heatmap-label">{t('cockpit.achievements')}</div>
|
<div className="cockpit-heatmap-label">{t('cockpit.achievements')}</div>
|
||||||
<div className="cockpit-achievements" data-testid="cockpit-achievements">
|
<div className="cockpit-achievements" data-testid="cockpit-achievements">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { mergeSubmissionPresets } from '@shared/builtin-templates'
|
import { mergeSubmissionPresets } from '@shared/builtin-templates'
|
||||||
|
import type { ExportFormat, ExportMatrixOptions, ExportScope } from '@shared/export-matrix'
|
||||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||||
@@ -8,7 +9,7 @@ import { useAppStore } from '@renderer/stores/useAppStore'
|
|||||||
import { useExportStore } from '@renderer/stores/useExportStore'
|
import { useExportStore } from '@renderer/stores/useExportStore'
|
||||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
type ExportKind = 'submission' | 'novel'
|
type ExportKind = 'submission' | 'matrix' | 'novel'
|
||||||
|
|
||||||
export function ExportModal(): React.JSX.Element | null {
|
export function ExportModal(): React.JSX.Element | null {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -18,6 +19,7 @@ export function ExportModal(): React.JSX.Element | null {
|
|||||||
const bookId = useBookStore((s) => s.currentBookId)
|
const bookId = useBookStore((s) => s.currentBookId)
|
||||||
const bookName = useBookStore((s) => s.books.find((b) => b.id === bookId)?.name)
|
const bookName = useBookStore((s) => s.books.find((b) => b.id === bookId)?.name)
|
||||||
const chapters = useBookStore((s) => s.chapters)
|
const chapters = useBookStore((s) => s.chapters)
|
||||||
|
const activeVolumeId = useBookStore((s) => s.activeVolumeId)
|
||||||
const target = useEditStore((s) => s.target)
|
const target = useEditStore((s) => s.target)
|
||||||
const customPresets = useSettingsStore((s) => s.submissionPresets)
|
const customPresets = useSettingsStore((s) => s.submissionPresets)
|
||||||
const defaultPresetId = useSettingsStore((s) => s.defaultSubmissionPresetId ?? 'builtin-webnovel')
|
const defaultPresetId = useSettingsStore((s) => s.defaultSubmissionPresetId ?? 'builtin-webnovel')
|
||||||
@@ -28,14 +30,23 @@ export function ExportModal(): React.JSX.Element | null {
|
|||||||
|
|
||||||
const [exportKind, setExportKind] = useState<ExportKind>('submission')
|
const [exportKind, setExportKind] = useState<ExportKind>('submission')
|
||||||
const [presetId, setPresetId] = useState(defaultPresetId)
|
const [presetId, setPresetId] = useState(defaultPresetId)
|
||||||
|
const [matrixFormat, setMatrixFormat] = useState<ExportFormat>('txt')
|
||||||
|
const [matrixScope, setMatrixScope] = useState<ExportScope>('chapter')
|
||||||
|
const [matrixOptions, setMatrixOptions] = useState<ExportMatrixOptions>({
|
||||||
|
includeAnnotations: false,
|
||||||
|
markAiContent: false
|
||||||
|
})
|
||||||
const [preview, setPreview] = useState('')
|
const [preview, setPreview] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [exportingNovel, setExportingNovel] = useState(false)
|
const [exporting, setExporting] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setExportKind('submission')
|
setExportKind('submission')
|
||||||
setPresetId(defaultPresetId)
|
setPresetId(defaultPresetId)
|
||||||
|
setMatrixFormat('txt')
|
||||||
|
setMatrixScope('chapter')
|
||||||
|
setMatrixOptions({ includeAnnotations: false, markAiContent: false })
|
||||||
}, [open, defaultPresetId])
|
}, [open, defaultPresetId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,6 +61,17 @@ export function ExportModal(): React.JSX.Element | null {
|
|||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [open, exportKind, bookId, chapterId, presetId])
|
}, [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<void> => {
|
const handleCopy = async (): Promise<void> => {
|
||||||
if (!bookId || !chapterId) return
|
if (!bookId || !chapterId) return
|
||||||
const text = await ipcCall(() =>
|
const text = await ipcCall(() =>
|
||||||
@@ -66,8 +88,30 @@ export function ExportModal(): React.JSX.Element | null {
|
|||||||
)
|
)
|
||||||
const defaultName = `${chapter.title}.txt`
|
const defaultName = `${chapter.title}.txt`
|
||||||
const saved = await ipcCall(() => window.electronAPI.export.saveTxt(text, defaultName))
|
const saved = await ipcCall(() => window.electronAPI.export.saveTxt(text, defaultName))
|
||||||
if (saved) {
|
if (saved) showToast(t('export.saved', { path: saved }))
|
||||||
showToast(t('export.saved', { path: saved }))
|
}
|
||||||
|
|
||||||
|
const handleMatrixExport = async (): Promise<void> => {
|
||||||
|
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
|
if (!bookId) return
|
||||||
const destPath = await ipcCall(() => window.electronAPI.pack.pickFile('save-novel'))
|
const destPath = await ipcCall(() => window.electronAPI.pack.pickFile('save-novel'))
|
||||||
if (!destPath) return
|
if (!destPath) return
|
||||||
setExportingNovel(true)
|
setExporting(true)
|
||||||
try {
|
try {
|
||||||
await ipcCall(() => window.electronAPI.pack.exportBook(bookId, destPath))
|
await ipcCall(() => window.electronAPI.pack.exportBook(bookId, destPath))
|
||||||
showToast(t('pack.exportBookSuccess', { name: bookName ?? '' }))
|
showToast(t('pack.exportBookSuccess', { name: bookName ?? '' }))
|
||||||
@@ -83,7 +127,7 @@ export function ExportModal(): React.JSX.Element | null {
|
|||||||
} catch {
|
} catch {
|
||||||
showToast(t('pack.exportBookFailed'))
|
showToast(t('pack.exportBookFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setExportingNovel(false)
|
setExporting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +148,7 @@ export function ExportModal(): React.JSX.Element | null {
|
|||||||
{t('export.title')}
|
{t('export.title')}
|
||||||
{exportKind === 'submission' && chapter ? ` — ${chapter.title}` : ''}
|
{exportKind === 'submission' && chapter ? ` — ${chapter.title}` : ''}
|
||||||
{exportKind === 'novel' && bookName ? ` — ${bookName}` : ''}
|
{exportKind === 'novel' && bookName ? ` — ${bookName}` : ''}
|
||||||
|
{exportKind === 'matrix' && bookName ? ` — ${bookName}` : ''}
|
||||||
</h3>
|
</h3>
|
||||||
<button type="button" className="modal-close" onClick={close}>
|
<button type="button" className="modal-close" onClick={close}>
|
||||||
✕
|
✕
|
||||||
@@ -118,9 +163,11 @@ export function ExportModal(): React.JSX.Element | null {
|
|||||||
onChange={(e) => setExportKind(e.target.value as ExportKind)}
|
onChange={(e) => setExportKind(e.target.value as ExportKind)}
|
||||||
>
|
>
|
||||||
<option value="submission">{t('export.kindSubmission')}</option>
|
<option value="submission">{t('export.kindSubmission')}</option>
|
||||||
|
<option value="matrix">{t('export.kindMatrix')}</option>
|
||||||
<option value="novel">{t('export.kindNovel')}</option>
|
<option value="novel">{t('export.kindNovel')}</option>
|
||||||
</select>
|
</select>
|
||||||
{exportKind === 'submission' ? (
|
|
||||||
|
{exportKind === 'submission' && (
|
||||||
<>
|
<>
|
||||||
<label className="form-label">{t('export.submissionPresets')}</label>
|
<label className="form-label">{t('export.submissionPresets')}</label>
|
||||||
<select
|
<select
|
||||||
@@ -140,7 +187,60 @@ export function ExportModal(): React.JSX.Element | null {
|
|||||||
{loading ? t('export.loading') : preview || t('export.noPreview')}
|
{loading ? t('export.loading') : preview || t('export.noPreview')}
|
||||||
</pre>
|
</pre>
|
||||||
</>
|
</>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{exportKind === 'matrix' && (
|
||||||
|
<>
|
||||||
|
<label className="form-label">{t('export.matrixFormat')}</label>
|
||||||
|
<select
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
data-testid="export-matrix-format"
|
||||||
|
value={matrixFormat}
|
||||||
|
onChange={(e) => setMatrixFormat(e.target.value as ExportFormat)}
|
||||||
|
>
|
||||||
|
<option value="txt">TXT</option>
|
||||||
|
<option value="md">Markdown</option>
|
||||||
|
<option value="docx">Word (.docx)</option>
|
||||||
|
<option value="pdf">PDF</option>
|
||||||
|
</select>
|
||||||
|
<label className="form-label">{t('export.matrixScope')}</label>
|
||||||
|
<select
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
data-testid="export-matrix-scope"
|
||||||
|
value={matrixScope}
|
||||||
|
onChange={(e) => setMatrixScope(e.target.value as ExportScope)}
|
||||||
|
>
|
||||||
|
<option value="chapter">{t('export.scopeChapter')}</option>
|
||||||
|
<option value="volume">{t('export.scopeVolume')}</option>
|
||||||
|
<option value="book">{t('export.scopeBook')}</option>
|
||||||
|
</select>
|
||||||
|
<label className="form-label">{t('export.matrixOptions')}</label>
|
||||||
|
<label style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="export-matrix-annotations"
|
||||||
|
checked={matrixOptions.includeAnnotations === true}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMatrixOptions((o) => ({ ...o, includeAnnotations: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{t('export.optionAnnotations')}
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="export-matrix-ai-mark"
|
||||||
|
checked={matrixOptions.markAiContent === true}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMatrixOptions((o) => ({ ...o, markAiContent: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{t('export.optionAiMark')}
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{exportKind === 'novel' && (
|
||||||
<p className="text-muted" style={{ lineHeight: 1.6 }}>
|
<p className="text-muted" style={{ lineHeight: 1.6 }}>
|
||||||
{t('export.novelDesc')}
|
{t('export.novelDesc')}
|
||||||
</p>
|
</p>
|
||||||
@@ -150,7 +250,7 @@ export function ExportModal(): React.JSX.Element | null {
|
|||||||
<button type="button" className="btn" onClick={close}>
|
<button type="button" className="btn" onClick={close}>
|
||||||
{t('dialog.cancel')}
|
{t('dialog.cancel')}
|
||||||
</button>
|
</button>
|
||||||
{exportKind === 'submission' ? (
|
{exportKind === 'submission' && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -171,15 +271,27 @@ export function ExportModal(): React.JSX.Element | null {
|
|||||||
{t('export.copyClipboard')}
|
{t('export.copyClipboard')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
)}
|
||||||
|
{exportKind === 'matrix' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
data-testid="export-matrix-btn"
|
||||||
|
disabled={matrixDisabled || exporting}
|
||||||
|
onClick={() => void handleMatrixExport()}
|
||||||
|
>
|
||||||
|
{exporting ? t('export.exporting') : t('export.matrixStart')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{exportKind === 'novel' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
data-testid="export-novel-btn"
|
data-testid="export-novel-btn"
|
||||||
disabled={!bookId || exportingNovel}
|
disabled={!bookId || exporting}
|
||||||
onClick={() => void handleExportNovel()}
|
onClick={() => void handleExportNovel()}
|
||||||
>
|
>
|
||||||
{exportingNovel ? t('pack.exporting') : t('export.exportNovel')}
|
{exporting ? t('pack.exporting') : t('export.exportNovel')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,40 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
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 {
|
export function FocusModeOverlay(): React.JSX.Element | null {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const focusMode = useAppStore((s) => s.focusMode)
|
const focusMode = useAppStore((s) => s.focusMode)
|
||||||
const setFocusMode = useAppStore((s) => s.setFocusMode)
|
const setFocusMode = useAppStore((s) => s.setFocusMode)
|
||||||
|
const ambientEnabled = useSettingsStore((s) => s.enableFocusAmbientSound === true)
|
||||||
|
const stopRef = useRef<(() => void) | null>(null)
|
||||||
|
const ctxRef = useRef<AudioContext | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focusMode) return
|
if (!focusMode) return
|
||||||
@@ -16,6 +45,25 @@ export function FocusModeOverlay(): React.JSX.Element | null {
|
|||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [focusMode, setFocusMode])
|
}, [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
|
if (!focusMode) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -234,6 +234,19 @@ export function SettingsPage(): React.JSX.Element {
|
|||||||
{t('settings.goalNotifications')}
|
{t('settings.goalNotifications')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="setting-item">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="settings-focus-ambient"
|
||||||
|
checked={settings.enableFocusAmbientSound === true}
|
||||||
|
onChange={(e) =>
|
||||||
|
void settings.update({ enableFocusAmbientSound: e.target.checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{t('settings.focusAmbientSound')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="setting-item">
|
<div className="setting-item">
|
||||||
<span>{t('settings.extractThreshold')}</span>
|
<span>{t('settings.extractThreshold')}</span>
|
||||||
<input
|
<input
|
||||||
@@ -263,7 +276,7 @@ export function SettingsPage(): React.JSX.Element {
|
|||||||
{section === 'shortcuts' && <ShortcutEditor />}
|
{section === 'shortcuts' && <ShortcutEditor />}
|
||||||
{section === 'about' && (
|
{section === 'about' && (
|
||||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||||
{t('app.name')} v1.3.0
|
{t('app.name')} v1.4.0
|
||||||
<br />
|
<br />
|
||||||
{t('app.tagline')}
|
{t('app.tagline')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -2347,6 +2347,32 @@
|
|||||||
margin-bottom: 8px;
|
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 {
|
.read-mode-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
Vendored
+4
@@ -60,6 +60,7 @@ import type {
|
|||||||
ImportSplitMode
|
ImportSplitMode
|
||||||
} from './types'
|
} from './types'
|
||||||
import type { PackImportStrategy, PackImportReport, PackProgressEvent } from './novel-pack'
|
import type { PackImportStrategy, PackImportReport, PackProgressEvent } from './novel-pack'
|
||||||
|
import type { ExportMatrixParams } from './export-matrix'
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
window: {
|
window: {
|
||||||
@@ -446,6 +447,9 @@ export interface ElectronAPI {
|
|||||||
copyClipboard: (text: string) => Promise<IpcResult<void>>
|
copyClipboard: (text: string) => Promise<IpcResult<void>>
|
||||||
saveTxt: (text: string, defaultName: string) => Promise<IpcResult<string | null>>
|
saveTxt: (text: string, defaultName: string) => Promise<IpcResult<string | null>>
|
||||||
}
|
}
|
||||||
|
exportMatrix: {
|
||||||
|
export: (params: ExportMatrixParams) => Promise<IpcResult<string | null>>
|
||||||
|
}
|
||||||
pack: {
|
pack: {
|
||||||
pickFile: (
|
pickFile: (
|
||||||
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
|
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export const IPC = {
|
|||||||
EXPORT_FORMAT_CHAPTER: 'export:formatChapter',
|
EXPORT_FORMAT_CHAPTER: 'export:formatChapter',
|
||||||
EXPORT_COPY_CLIPBOARD: 'export:copyClipboard',
|
EXPORT_COPY_CLIPBOARD: 'export:copyClipboard',
|
||||||
EXPORT_SAVE_TXT: 'export:saveTxt',
|
EXPORT_SAVE_TXT: 'export:saveTxt',
|
||||||
|
EXPORT_MATRIX_EXPORT: 'exportMatrix:export',
|
||||||
COCKPIT_SUMMARY: 'cockpit:summary',
|
COCKPIT_SUMMARY: 'cockpit:summary',
|
||||||
COCKPIT_MARK_SEEN: 'cockpit:markSeen',
|
COCKPIT_MARK_SEEN: 'cockpit:markSeen',
|
||||||
COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen',
|
COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen',
|
||||||
|
|||||||
@@ -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 = '<p>第一段。</p><p>第二段。</p>'
|
||||||
|
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('正文内容。')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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: '<p>矩阵导出测试正文。</p>'
|
||||||
|
})
|
||||||
|
|
||||||
|
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: '<p>Docx正文。</p>' })
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user