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:
@@ -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, '"')
|
||||
}
|
||||
Reference in New Issue
Block a user