c5c0b7329b
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>
112 lines
3.5 KiB
TypeScript
112 lines
3.5 KiB
TypeScript
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')
|
|
}
|