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