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 [AI生成内容] ${escapeHtml(p)}
- {t('app.name')} v1.3.0
+ {t('app.name')} v1.4.0
第一段。 第二段。 矩阵导出测试正文。 Docx正文。${block.annotations.map((a) => `
${idx + 1}. ${escapeHtml(block.title)}
${ai}${body}${notes}
+ {todayOutlineTasks.map((item) => (
+
+
{t('app.tagline')}