返回 oh-my-ppt
page-merge-service.ts
根目录 / src / main / session / page-merge-service.ts
1 import fs from 'fs'
2 import path from 'path'
3 import log from 'electron-log/main.js'
4 import { customAlphabet, nanoid } from 'nanoid'
5 import type { IpcContext } from '../ipc/context'
6 import type { SourcePageSkeletonRecord } from '../db/database'
7 import { SESSION_ASSET_FILE_NAMES } from './template-builder'
8 import { validatePersistedPageHtml } from '../presentation/html/html-utils'
9 import {
10 ensureHistoryBaselineSafe,
11 recordHistoryOperationStrict
12 } from '../history/git-history-service'
13 import {
14 loadEditableSessionPages,
15 persistManagedPages,
16 type ManagedPage
17 } from './page-management-service'
18 import {
19 collectMergedPageResourceKeys,
20 collectUnsafeMergedPageResourceReferences,
21 extractMergePageFontProfile,
22 isMergePathInside,
23 resolveMergeFileInside,
24 rewriteMergedPageHtml,
25 type MergePageFontProfile
26 } from './page-merge-rewriter'
27 import { mapPageMergeConcurrent } from './page-merge-concurrency'
28 import { buildFontHeadTags } from '../presentation/fonts/font-registry'
29 import { normalizeDesignContract } from '../presentation/design-contract'
30 import { PageMergeError, type PageMergeDisabledReason } from '../../shared/page-merge'
31 import {
32 requireSessionSlideSize,
33 requireSlideSize,
34 type SlideSizePresetId
35 } from '@shared/slide-size'
36 import { listTemplates, loadTemplateManifest } from '../templates/template-service'
37 import { resolveTemplateRelativePath } from '../templates/template-paths'
38
39 export const MAX_MERGE_PAGE_COUNT = 50
40 const pageSlugId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 10)
41 const SHARED_RUNTIME_ASSETS = new Set(
42 SESSION_ASSET_FILE_NAMES.map((item) => item.replace(/^\.\//, '').replace(/\\/g, '/'))
43 )
44 const FONT_RESOURCE_EXTENSIONS = new Set(['.woff', '.woff2', '.ttf', '.otf', '.eot'])
45
46 export interface MergeSourceSessionSummary {
47 id: string
48 title: string
49 pageCount: number
50 slideSizeId: SlideSizePresetId
51 slideWidth: number
52 slideHeight: number
53 updatedAt: number
54 status: string
55 selectable: boolean
56 disabledReason?: PageMergeDisabledReason
57 }
58
59 export interface MergeSourcePageSummary {
60 id: string
61 pageId: string
62 pageNumber: number
63 title: string
64 contentOutline?: string | null
65 slideSizeId: SlideSizePresetId
66 slideWidth: number
67 slideHeight: number
68 htmlPath?: string
69 sourceUrl?: string
70 status?: string
71 selectable: boolean
72 disabledReason?: PageMergeDisabledReason
73 }
74
75 interface PreparedMergedPage {
76 page: ManagedPage
77 sourceSkeleton?: SourcePageSkeletonRecord
78 targetSourceDocumentPath?: string
79 }
80
81 interface PageMergeLogContext {
82 batchId: string
83 targetSessionId: string
84 sourceSessionId: string
85 sourceType?: string
86 }
87
88 const mergeLog = (
89 level: 'info' | 'warn' | 'error',
90 stage: string,
91 context: PageMergeLogContext,
92 details: Record<string, unknown> = {}
93 ): void => {
94 log[level]('[page-merge]', { stage, ...context, ...details })
95 }
96
97 const runMergeRollbackStep = async (
98 stage: string,
99 context: PageMergeLogContext,
100 task: () => Promise<unknown>
101 ): Promise<void> => {
102 const startedAt = Date.now()
103 mergeLog('info', `rollback:${stage}:start`, context)
104 try {
105 await task()
106 mergeLog('info', `rollback:${stage}:completed`, context, {
107 durationMs: Date.now() - startedAt
108 })
109 } catch (error) {
110 mergeLog('warn', `rollback:${stage}:failed`, context, {
111 durationMs: Date.now() - startedAt,
112 error: error instanceof Error ? error.message : String(error)
113 })
114 }
115 }
116
117 const shouldKeepTargetRuntimeAsset = (resourceKey: string): boolean => {
118 if (!resourceKey.startsWith('assets/')) return false
119 const assetRelative = resourceKey.slice('assets/'.length)
120 return SHARED_RUNTIME_ASSETS.has(assetRelative)
121 }
122
123 const collectCssDependencyKeys = (css: string, cssResourceKey: string): string[] => {
124 const keys = new Set<string>()
125 const baseDir = path.posix.dirname(cssResourceKey)
126 css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (full, _quote, rawUrl: string) => {
127 const trimmed = rawUrl.trim()
128 if (
129 !trimmed ||
130 trimmed.startsWith('#') ||
131 trimmed.startsWith('/') ||
132 /^(?:data|blob|https?|local-asset):/i.test(trimmed)
133 ) {
134 return full
135 }
136 const pathname = trimmed.split(/[?#]/, 1)[0].replace(/\\/g, '/')
137 const normalized = path.posix.normalize(path.posix.join(baseDir, pathname))
138 if (normalized && !normalized.startsWith('../')) keys.add(normalized)
139 return full
140 })
141 css.replace(/@import\s+(['"])([^'"]+)\1/gi, (full, _quote, rawUrl: string) => {
142 const trimmed = rawUrl.trim()
143 if (
144 !trimmed ||
145 trimmed.startsWith('/') ||
146 /^(?:data|blob|https?|local-asset):/i.test(trimmed)
147 ) {
148 return full
149 }
150 const pathname = trimmed.split(/[?#]/, 1)[0].replace(/\\/g, '/')
151 const normalized = path.posix.normalize(path.posix.join(baseDir, pathname))
152 if (normalized && !normalized.startsWith('../')) keys.add(normalized)
153 return full
154 })
155 return Array.from(keys)
156 }
157
158 const escapeCssRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
159
160 const sanitizeMergedStylesheet = (css: string, targetBodyFont: string): string => {
161 const sourceFontFamilies = new Set<string>()
162 css.replace(/@font-face\s*\{([^{}]*)\}/gi, (_full, body: string) => {
163 const family = body.match(/font-family\s*:\s*(["']?)([^;"'}]+)\1\s*;/i)?.[2]?.trim()
164 if (family) sourceFontFamilies.add(family)
165 return ''
166 })
167 let sanitized = css.replace(/@font-face\s*\{[^{}]*\}/gi, '')
168 for (const family of sourceFontFamilies) {
169 const escaped = escapeCssRegExp(family)
170 sanitized = sanitized.replace(
171 new RegExp(`(["'])${escaped}\\1`, 'gi'),
172 (_match, quote: string) => `${quote}${targetBodyFont}${quote}`
173 )
174 sanitized = sanitized.replace(
175 new RegExp(`(font-family\\s*:\\s*)${escaped}(?=\\s*(?:[,;}]))`, 'gi'),
176 `$1${targetBodyFont}`
177 )
178 }
179 return sanitized
180 }
181
182 const copyPageResources = async (args: {
183 html: string
184 sourceProjectDir: string
185 tempProjectDir: string
186 batchId: string
187 nextPageId: string
188 targetBodyFont: string
189 preserveFonts?: boolean
190 }): Promise<Map<string, string>> => {
191 const unsafeReferences = collectUnsafeMergedPageResourceReferences(args.html)
192 if (unsafeReferences.length > 0) {
193 throw new PageMergeError(
194 'PAGE_MERGE_PAGE_COPY_FAILED',
195 `页面包含越界资源路径: ${unsafeReferences.join(', ')}`
196 )
197 }
198 const resourcePathMap = new Map<string, string>()
199 const pendingResourceKeys = [...collectMergedPageResourceKeys(args.html)]
200 const copiedResourceKeys = new Set<string>()
201 while (pendingResourceKeys.length > 0) {
202 const resourceKey = pendingResourceKeys.shift()!
203 if (copiedResourceKeys.has(resourceKey)) continue
204 copiedResourceKeys.add(resourceKey)
205 if (shouldKeepTargetRuntimeAsset(resourceKey)) continue
206 if (!args.preserveFonts && resourceKey.startsWith('assets/fonts/')) continue
207 if (
208 !args.preserveFonts &&
209 FONT_RESOURCE_EXTENSIONS.has(path.extname(resourceKey).toLowerCase())
210 )
211 continue
212 const sourcePath = await resolveMergeFileInside(
213 path.resolve(args.sourceProjectDir, resourceKey),
214 args.sourceProjectDir
215 )
216 if (!sourcePath) {
217 throw new PageMergeError('PAGE_MERGE_PAGE_COPY_FAILED', `页面资源不存在: ${resourceKey}`)
218 }
219 const stat = await fs.promises.stat(sourcePath)
220 if (!stat.isFile()) continue
221 const targetRelative = path.posix.join(
222 'assets',
223 'merged-pages',
224 args.batchId,
225 args.nextPageId,
226 resourceKey
227 )
228 const tempTargetPath = path.join(args.tempProjectDir, ...targetRelative.split('/'))
229 await fs.promises.mkdir(path.dirname(tempTargetPath), { recursive: true })
230 if (path.extname(resourceKey).toLowerCase() === '.css') {
231 const css = await fs.promises.readFile(sourcePath, 'utf-8')
232 await fs.promises.writeFile(
233 tempTargetPath,
234 args.preserveFonts ? css : sanitizeMergedStylesheet(css, args.targetBodyFont),
235 'utf-8'
236 )
237 pendingResourceKeys.push(...collectCssDependencyKeys(css, resourceKey))
238 } else {
239 await fs.promises.copyFile(sourcePath, tempTargetPath)
240 }
241 resourcePathMap.set(resourceKey, `./${targetRelative}`)
242 }
243 return resourcePathMap
244 }
245
246 const prepareSourceDocument = async (args: {
247 skeleton?: SourcePageSkeletonRecord
248 sourceProjectDir: string
249 tempProjectDir: string
250 batchId: string
251 nextPageId: string
252 }): Promise<string | undefined> => {
253 const sourceDocumentPath = args.skeleton?.source_document_path?.trim()
254 if (!sourceDocumentPath) return undefined
255 const relative = sourceDocumentPath.replace(/^\/?docs\//, '')
256 if (relative === sourceDocumentPath || !relative || relative.startsWith('../')) {
257 return sourceDocumentPath
258 }
259 const sourcePath = await resolveMergeFileInside(
260 path.resolve(args.sourceProjectDir, 'docs', relative),
261 path.join(args.sourceProjectDir, 'docs')
262 )
263 if (!sourcePath) {
264 return `merged-session:${args.batchId}`
265 }
266 const targetRelative = path.posix.join(
267 'docs',
268 'merged-pages',
269 args.batchId,
270 args.nextPageId,
271 path.posix.basename(relative.replace(/\\/g, '/'))
272 )
273 const tempTargetPath = path.join(args.tempProjectDir, ...targetRelative.split('/'))
274 await fs.promises.mkdir(path.dirname(tempTargetPath), { recursive: true })
275 await fs.promises.copyFile(sourcePath, tempTargetPath)
276 return `/${targetRelative}`
277 }
278
279 const movePreparedEntries = async (
280 tempProjectDir: string,
281 targetProjectDir: string,
282 movedTargetFontFiles: string[],
283 relativeDir = ''
284 ): Promise<void> => {
285 const entries = await fs.promises.readdir(tempProjectDir, { withFileTypes: true })
286 for (const entry of entries) {
287 const source = path.join(tempProjectDir, entry.name)
288 const target = path.join(targetProjectDir, entry.name)
289 const relativePath = path.posix.join(relativeDir, entry.name)
290 if (entry.isDirectory()) {
291 await fs.promises.mkdir(target, { recursive: true })
292 await movePreparedEntries(source, target, movedTargetFontFiles, relativePath)
293 continue
294 }
295 const targetExisted = fs.existsSync(target)
296 if (targetExisted && relativePath.startsWith('assets/fonts/')) {
297 await fs.promises.rm(source, { force: true })
298 continue
299 }
300 await fs.promises.mkdir(path.dirname(target), { recursive: true })
301 await fs.promises.rename(source, target)
302 if (!targetExisted && relativePath.startsWith('assets/fonts/')) {
303 movedTargetFontFiles.push(target)
304 }
305 }
306 }
307
308 const resolveTargetFontProfile = async (args: {
309 pages: ManagedPage[]
310 fontProjectDir: string
311 designContract: unknown
312 }): Promise<{ profile: MergePageFontProfile; source: 'target-page' | 'design-contract' }> => {
313 for (const page of args.pages) {
314 if (page.status !== 'completed' || !fs.existsSync(page.htmlPath)) continue
315 const html = await fs.promises.readFile(page.htmlPath, 'utf-8')
316 const profile = extractMergePageFontProfile(html)
317 if (profile) return { profile, source: 'target-page' }
318 }
319
320 const designContract = normalizeDesignContract(args.designContract)
321 const headTags = await buildFontHeadTags({
322 titleFont: designContract.titleFont,
323 bodyFont: designContract.bodyFont,
324 projectDir: args.fontProjectDir
325 })
326 const profile = extractMergePageFontProfile(`<html><head>${headTags}</head><body></body></html>`)
327 if (!profile) {
328 throw new PageMergeError('PAGE_MERGE_TARGET_FONT_UNAVAILABLE', '无法读取当前模板字体配置')
329 }
330 return { profile, source: 'design-contract' }
331 }
332
333 const toGeneratedPages = (
334 ctx: IpcContext,
335 pages: ManagedPage[]
336 ): Array<{
337 id: string
338 pageNumber: number
339 pageId: string
340 title: string
341 contentOutline?: string | null
342 html: string
343 htmlPath: string
344 sourceUrl?: string
345 status?: string
346 error?: string | null
347 }> =>
348 pages.map((page) => ({
349 id: page.id,
350 pageNumber: page.pageNumber,
351 pageId: page.pageId,
352 title: page.title,
353 contentOutline: page.contentOutline?.trim() || null,
354 html: '',
355 htmlPath: page.htmlPath,
356 sourceUrl: ctx.getPageSourceUrl(page.htmlPath),
357 status: page.status,
358 error: page.error
359 }))
360
361 export async function listMergeSourceSessions(
362 ctx: IpcContext,
363 targetSessionId: string
364 ): Promise<MergeSourceSessionSummary[]> {
365 const [sessions, targetSession] = await Promise.all([
366 ctx.db.listSessionsWithPageCounts(500),
367 ctx.db.getSession(targetSessionId)
368 ])
369 const targetSlideSize = requireSessionSlideSize(targetSession)
370 return sessions
371 .filter(({ session }) => session.id !== targetSessionId)
372 .map(({ session, pageCount }) => {
373 const runState = ctx.sessionRunStates.get(session.id)
374 const running =
375 session.status === 'active' ||
376 runState?.status === 'queued' ||
377 runState?.status === 'running'
378 const sourceSlideSize = requireSessionSlideSize(session)
379 const sizeMatches =
380 sourceSlideSize.width === targetSlideSize.width &&
381 sourceSlideSize.height === targetSlideSize.height
382 const selectable = pageCount > 0 && !running && sizeMatches
383 return {
384 id: session.id,
385 title: session.title || '',
386 pageCount,
387 slideSizeId: sourceSlideSize.id,
388 slideWidth: sourceSlideSize.width,
389 slideHeight: sourceSlideSize.height,
390 updatedAt: session.updated_at,
391 status: session.status,
392 selectable,
393 disabledReason: running
394 ? 'PAGE_MERGE_SESSION_BUSY'
395 : pageCount === 0
396 ? 'PAGE_MERGE_SESSION_EMPTY'
397 : !sizeMatches
398 ? 'PAGE_MERGE_SLIDE_SIZE_MISMATCH'
399 : undefined
400 }
401 })
402 }
403
404 export async function listMergeSourcePages(
405 ctx: IpcContext,
406 sourceSessionId: string
407 ): Promise<MergeSourcePageSummary[]> {
408 const { session, pages, projectDir } = await loadEditableSessionPages(ctx, sourceSessionId)
409 const slideSize = requireSessionSlideSize(session)
410 return Promise.all(
411 pages.map(async (page) => {
412 const safeHtmlPath = await resolveMergeFileInside(page.htmlPath, projectDir)
413 const selectable = page.status === 'completed' && Boolean(safeHtmlPath)
414 return {
415 id: page.id,
416 pageId: page.pageId,
417 pageNumber: page.pageNumber,
418 title: page.title,
419 contentOutline: page.contentOutline?.trim() || null,
420 slideSizeId: slideSize.id,
421 slideWidth: slideSize.width,
422 slideHeight: slideSize.height,
423 htmlPath: safeHtmlPath || undefined,
424 sourceUrl: safeHtmlPath ? ctx.getPageSourceUrl(safeHtmlPath) : undefined,
425 status: page.status,
426 selectable,
427 disabledReason:
428 page.status !== 'completed'
429 ? 'PAGE_MERGE_PAGE_INCOMPLETE'
430 : !safeHtmlPath
431 ? 'PAGE_MERGE_PAGE_FILE_MISSING'
432 : undefined
433 }
434 })
435 )
436 }
437
438 export interface MergeTemplateSourceSummary {
439 id: string
440 title: string
441 pageCount: number
442 slideSizeId: SlideSizePresetId
443 slideWidth: number
444 slideHeight: number
445 updatedAt: number
446 thumbnailPath: string | null
447 selectable: boolean
448 disabledReason?: PageMergeDisabledReason
449 isSource: boolean
450 }
451
452 export async function listMergeSourceTemplates(
453 ctx: IpcContext,
454 targetSessionId: string
455 ): Promise<MergeTemplateSourceSummary[]> {
456 const targetSession = await ctx.db.getSession(targetSessionId)
457 if (!targetSession) return []
458 const targetSlideSize = requireSessionSlideSize(targetSession)
459 let sourceTemplateId = ''
460 try {
461 const meta = JSON.parse(targetSession.metadata || '{}') as Record<string, unknown>
462 sourceTemplateId = typeof meta.templateId === 'string' ? meta.templateId.trim() : ''
463 } catch {
464 sourceTemplateId = ''
465 }
466 const { items } = await listTemplates()
467 const summaries: MergeTemplateSourceSummary[] = items.map((item) => {
468 const sizeMatches =
469 item.slideWidth === targetSlideSize.width && item.slideHeight === targetSlideSize.height
470 const hasPages = item.previewPages.length > 0
471 return {
472 id: item.id,
473 title: item.name,
474 pageCount: item.pageCount,
475 slideSizeId: item.slideSizeId,
476 slideWidth: item.slideWidth,
477 slideHeight: item.slideHeight,
478 updatedAt: item.updatedAt,
479 thumbnailPath: item.thumbnailPath,
480 selectable: sizeMatches && hasPages,
481 disabledReason: !sizeMatches
482 ? ('PAGE_MERGE_SLIDE_SIZE_MISMATCH' as PageMergeDisabledReason)
483 : !hasPages
484 ? ('PAGE_MERGE_SESSION_EMPTY' as PageMergeDisabledReason)
485 : undefined,
486 isSource: Boolean(sourceTemplateId) && item.id === sourceTemplateId
487 }
488 })
489 return summaries.sort((a, b) => {
490 if (a.isSource !== b.isSource) return a.isSource ? -1 : 1
491 return b.updatedAt - a.updatedAt
492 })
493 }
494
495 export async function listMergeSourceTemplatePages(
496 ctx: IpcContext,
497 targetSessionId: string,
498 templateId: string
499 ): Promise<MergeSourcePageSummary[]> {
500 const targetSession = await ctx.db.getSession(targetSessionId)
501 if (!targetSession) {
502 throw new PageMergeError('PAGE_MERGE_SESSION_NOT_FOUND', '当前会话不存在')
503 }
504 const targetSlideSize = requireSessionSlideSize(targetSession)
505 const loaded = await loadTemplateManifest(templateId).catch(() => {
506 throw new PageMergeError('PAGE_MERGE_SESSION_NOT_FOUND', '模板不存在')
507 })
508 const { manifest, templateDir } = loaded
509 const slideSize = requireSlideSize({
510 id: manifest.slideSizeId,
511 width: manifest.slideWidth,
512 height: manifest.slideHeight
513 })
514 if (slideSize.width !== targetSlideSize.width || slideSize.height !== targetSlideSize.height) {
515 throw new PageMergeError(
516 'PAGE_MERGE_SLIDE_SIZE_MISMATCH',
517 '模板与当前会话的画布尺寸不同,不能添加页面'
518 )
519 }
520 return Promise.all(
521 manifest.pages.map(async (page) => {
522 const htmlPath = resolveTemplateRelativePath(templateDir, page.htmlPath)
523 const safeHtmlPath =
524 htmlPath && fs.existsSync(htmlPath)
525 ? await resolveMergeFileInside(htmlPath, templateDir)
526 : null
527 const selectable = Boolean(safeHtmlPath)
528 return {
529 id: `${manifest.id}:${page.pageNumber}`,
530 pageId: page.pageId,
531 pageNumber: page.pageNumber,
532 title: page.title,
533 contentOutline: null,
534 slideSizeId: slideSize.id,
535 slideWidth: slideSize.width,
536 slideHeight: slideSize.height,
537 htmlPath: safeHtmlPath || undefined,
538 sourceUrl: safeHtmlPath ? ctx.getPageSourceUrl(safeHtmlPath) : undefined,
539 status: 'completed',
540 selectable,
541 disabledReason: !selectable
542 ? ('PAGE_MERGE_PAGE_FILE_MISSING' as PageMergeDisabledReason)
543 : undefined
544 }
545 })
546 )
547 }
548
549 interface MergeSourceData {
550 selectedSourcePages: ManagedPage[]
551 sourcePageHtmlPaths: Map<string, string>
552 sourceProjectDir: string
553 sourceTitle: string
554 skeletonByPageNumber: Map<number, SourcePageSkeletonRecord>
555 sourcePageCount: number
556 preserveFonts: boolean
557 }
558
559 async function loadMergeSource(
560 ctx: IpcContext,
561 args: {
562 sourceType: 'session' | 'template'
563 sourceId: string
564 targetSlideSize: { width: number; height: number }
565 sourcePageIds: string[]
566 }
567 ): Promise<MergeSourceData> {
568 if (args.sourceType === 'template') {
569 return loadTemplateMergeSource(args.sourceId, args.targetSlideSize, args.sourcePageIds)
570 }
571 return loadSessionMergeSource(ctx, args.sourceId, args.targetSlideSize, args.sourcePageIds)
572 }
573
574 async function loadSessionMergeSource(
575 ctx: IpcContext,
576 sourceSessionId: string,
577 targetSlideSize: { width: number; height: number },
578 sourcePageIds: string[]
579 ): Promise<MergeSourceData> {
580 const sourceSession = await ctx.db.getSession(sourceSessionId)
581 if (!sourceSession) {
582 throw new PageMergeError('PAGE_MERGE_SESSION_NOT_FOUND', '源会话不存在')
583 }
584 const runState = ctx.sessionRunStates.get(sourceSessionId)
585 if (
586 sourceSession.status === 'active' ||
587 runState?.status === 'queued' ||
588 runState?.status === 'running'
589 ) {
590 throw new PageMergeError('PAGE_MERGE_SESSION_BUSY', '源会话正在生成,暂时不能添加页面')
591 }
592 const sourceSlideSize = requireSessionSlideSize(sourceSession)
593 if (
594 sourceSlideSize.width !== targetSlideSize.width ||
595 sourceSlideSize.height !== targetSlideSize.height
596 ) {
597 throw new PageMergeError(
598 'PAGE_MERGE_SLIDE_SIZE_MISMATCH',
599 '源会话与当前会话的画布尺寸不同,不能混合添加页面'
600 )
601 }
602 const sourceData = await loadEditableSessionPages(ctx, sourceSessionId)
603 const sourcePageMap = new Map(sourceData.pages.map((page) => [page.id, page]))
604 const sourcePageHtmlPaths = new Map<string, string>()
605 const selectedSourcePages = sourcePageIds.map((id) => {
606 const page = sourcePageMap.get(id)
607 if (!page) {
608 throw new PageMergeError('PAGE_MERGE_SOURCE_PAGE_NOT_FOUND', `源页面不存在: ${id}`)
609 }
610 if (page.status !== 'completed') {
611 throw new PageMergeError(
612 'PAGE_MERGE_SOURCE_PAGE_UNAVAILABLE',
613 `页面尚未生成完成: ${page.title}`
614 )
615 }
616 const safeHtmlPath = fs.existsSync(page.htmlPath)
617 ? fs.realpathSync.native(page.htmlPath)
618 : null
619 if (
620 !safeHtmlPath ||
621 !isMergePathInside(safeHtmlPath, fs.realpathSync.native(sourceData.projectDir))
622 ) {
623 throw new PageMergeError(
624 'PAGE_MERGE_SOURCE_PAGE_UNAVAILABLE',
625 `页面文件不存在: ${page.title}`
626 )
627 }
628 sourcePageHtmlPaths.set(page.id, safeHtmlPath)
629 return page
630 })
631 selectedSourcePages.sort((left, right) => left.pageNumber - right.pageNumber)
632 const sourceSkeletons = await ctx.db.listSourcePageSkeletons(sourceSessionId)
633 return {
634 selectedSourcePages,
635 sourcePageHtmlPaths,
636 sourceProjectDir: sourceData.projectDir,
637 sourceTitle: String(sourceData.deckTitle || ''),
638 skeletonByPageNumber: new Map(sourceSkeletons.map((item) => [item.page_number, item])),
639 sourcePageCount: sourceData.pages.length,
640 preserveFonts: false
641 }
642 }
643
644 async function loadTemplateMergeSource(
645 templateId: string,
646 targetSlideSize: { width: number; height: number },
647 sourcePageIds: string[]
648 ): Promise<MergeSourceData> {
649 const loaded = await loadTemplateManifest(templateId).catch(() => {
650 throw new PageMergeError('PAGE_MERGE_SESSION_NOT_FOUND', '模板不存在')
651 })
652 const { manifest, templateDir } = loaded
653 const sourceSlideSize = requireSlideSize({
654 id: manifest.slideSizeId,
655 width: manifest.slideWidth,
656 height: manifest.slideHeight
657 })
658 if (
659 sourceSlideSize.width !== targetSlideSize.width ||
660 sourceSlideSize.height !== targetSlideSize.height
661 ) {
662 throw new PageMergeError(
663 'PAGE_MERGE_SLIDE_SIZE_MISMATCH',
664 '模板与当前会话的画布尺寸不同,不能添加页面'
665 )
666 }
667 const sortedPages = manifest.pages.slice().sort((a, b) => a.pageNumber - b.pageNumber)
668 const sourcePages: ManagedPage[] = sortedPages.map((page) => {
669 const resolved = resolveTemplateRelativePath(templateDir, page.htmlPath)
670 return {
671 id: `${manifest.id}:${page.pageNumber}`,
672 pageNumber: page.pageNumber,
673 pageId: page.pageId,
674 title: page.title,
675 contentOutline: null,
676 htmlPath: resolved || path.join(templateDir, page.htmlPath),
677 status: 'completed' as const,
678 error: null
679 }
680 })
681 const sourcePageMap = new Map(sourcePages.map((page) => [page.id, page]))
682 const sourcePageHtmlPaths = new Map<string, string>()
683 const realTemplateDir = fs.realpathSync.native(templateDir)
684 const selectedSourcePages = sourcePageIds.map((id) => {
685 const page = sourcePageMap.get(id)
686 if (!page) {
687 throw new PageMergeError('PAGE_MERGE_SOURCE_PAGE_NOT_FOUND', `模板页面不存在: ${id}`)
688 }
689 const safeHtmlPath = fs.existsSync(page.htmlPath)
690 ? fs.realpathSync.native(page.htmlPath)
691 : null
692 if (!safeHtmlPath || !isMergePathInside(safeHtmlPath, realTemplateDir)) {
693 throw new PageMergeError(
694 'PAGE_MERGE_SOURCE_PAGE_UNAVAILABLE',
695 `模板页面文件不存在: ${page.title}`
696 )
697 }
698 sourcePageHtmlPaths.set(page.id, safeHtmlPath)
699 return page
700 })
701 selectedSourcePages.sort((left, right) => left.pageNumber - right.pageNumber)
702 return {
703 selectedSourcePages,
704 sourcePageHtmlPaths,
705 sourceProjectDir: templateDir,
706 sourceTitle: manifest.name,
707 skeletonByPageNumber: new Map(),
708 sourcePageCount: sourcePages.length,
709 preserveFonts: true
710 }
711 }
712
713 export async function mergeSessionPages(
714 ctx: IpcContext,
715 args: {
716 targetSessionId: string
717 sourceType?: 'session' | 'template'
718 sourceSessionId?: string
719 sourceTemplateId?: string
720 sourcePageIds: string[]
721 }
722 ): Promise<{
723 generatedPages: ReturnType<typeof toGeneratedPages>
724 insertedPageIds: string[]
725 selectedPageId: string
726 }> {
727 const startedAt = Date.now()
728 const batchId = `mg_${nanoid(10)}`
729 const sourceType = args.sourceType ?? 'session'
730 const sourceId = sourceType === 'template' ? args.sourceTemplateId : args.sourceSessionId
731 const logContext: PageMergeLogContext = {
732 batchId,
733 targetSessionId: args.targetSessionId,
734 sourceSessionId: sourceId || '',
735 sourceType
736 }
737 let stage = 'validate-request'
738 mergeLog('info', 'request:start', logContext, { requestedPageCount: args.sourcePageIds.length })
739 if (!sourceId) {
740 throw new PageMergeError('PAGE_MERGE_INVALID_REQUEST', '缺少来源标识')
741 }
742 if (sourceType === 'session' && args.targetSessionId === sourceId) {
743 throw new PageMergeError('PAGE_MERGE_SAME_SESSION', '不能从当前会话添加页面')
744 }
745 const uniquePageIds = Array.from(
746 new Set(args.sourcePageIds.map((item) => item.trim()).filter(Boolean))
747 )
748 if (uniquePageIds.length === 0) {
749 throw new PageMergeError('PAGE_MERGE_NO_PAGE_SELECTED', '请选择要添加的页面')
750 }
751 if (uniquePageIds.length !== args.sourcePageIds.length) {
752 throw new PageMergeError('PAGE_MERGE_INVALID_REQUEST', '页面列表包含重复项')
753 }
754 if (uniquePageIds.length > MAX_MERGE_PAGE_COUNT) {
755 throw new PageMergeError(
756 'PAGE_MERGE_PAGE_LIMIT_EXCEEDED',
757 `一次最多添加 ${MAX_MERGE_PAGE_COUNT} 页`
758 )
759 }
760
761 stage = 'load-target'
762 const targetSession = await ctx.db.getSession(args.targetSessionId)
763 if (!targetSession) {
764 throw new PageMergeError('PAGE_MERGE_SESSION_NOT_FOUND', '当前会话不存在')
765 }
766 const targetSlideSize = requireSessionSlideSize(targetSession)
767 const targetRunState = ctx.sessionRunStates.get(args.targetSessionId)
768 if (
769 targetSession.status === 'active' ||
770 targetRunState?.status === 'queued' ||
771 targetRunState?.status === 'running'
772 ) {
773 throw new PageMergeError('PAGE_MERGE_SESSION_BUSY', '当前会话正在生成,暂时不能添加页面')
774 }
775 const targetData = await loadEditableSessionPages(ctx, args.targetSessionId)
776 const targetProject = await ctx.db.getProject(args.targetSessionId)
777 mergeLog('info', 'sessions:validated', logContext, {
778 sourceType,
779 targetStatus: targetSession.status
780 })
781
782 stage = 'load-source'
783 const source = await loadMergeSource(ctx, {
784 sourceType,
785 sourceId,
786 targetSlideSize,
787 sourcePageIds: uniquePageIds
788 })
789 const {
790 selectedSourcePages,
791 sourcePageHtmlPaths,
792 sourceProjectDir,
793 sourceTitle,
794 skeletonByPageNumber,
795 preserveFonts
796 } = source
797 mergeLog('info', 'pages:selected', logContext, {
798 sourcePageCount: source.sourcePageCount,
799 targetPageCount: targetData.pages.length,
800 selectedPageCount: selectedSourcePages.length,
801 selectedPageNumbers: selectedSourcePages.map((page) => page.pageNumber)
802 })
803 const tempRoot = path.join(targetData.projectDir, '.merge-pages-tmp', batchId)
804 await fs.promises.mkdir(tempRoot, { recursive: true })
805 mergeLog('info', 'workspace:prepared', logContext, {
806 sourceSkeletonCount: skeletonByPageNumber.size,
807 tempRoot
808 })
809 let preparedPages: PreparedMergedPage[] = []
810 let movedTargetFontFiles: string[] = []
811 const insertedPageIds: string[] = []
812 const insertedPageNumbers: number[] = []
813 const previousIndex = fs.existsSync(targetData.indexPath)
814 ? await fs.promises.readFile(targetData.indexPath)
815 : null
816 let previousMetadata: Record<string, unknown> = {}
817 try {
818 previousMetadata = JSON.parse(targetSession.metadata || '{}') as Record<string, unknown>
819 } catch {
820 previousMetadata = {}
821 }
822
823 try {
824 stage = 'resolve-target-fonts'
825 const targetFontResult = await resolveTargetFontProfile({
826 pages: targetData.pages,
827 fontProjectDir: tempRoot,
828 designContract: targetSession.designContract
829 })
830 const targetFontProfile = targetFontResult.profile
831 mergeLog('info', 'fonts:resolved', logContext, {
832 source: targetFontResult.source,
833 titleFont: targetFontProfile.titleFont,
834 bodyFont: targetFontProfile.bodyFont
835 })
836 stage = 'prepare-pages'
837 preparedPages = await mapPageMergeConcurrent(
838 selectedSourcePages,
839 async (sourcePage, sourceIndex) => {
840 const pageStartedAt = Date.now()
841 const nextPageId = `page-${pageSlugId()}`
842 const nextEntityId = nanoid()
843 const nextPageNumber = targetData.pages.length + sourceIndex + 1
844 const sourceHtmlPath = sourcePageHtmlPaths.get(sourcePage.id)
845 if (!sourceHtmlPath) {
846 throw new PageMergeError(
847 'PAGE_MERGE_SOURCE_PAGE_UNAVAILABLE',
848 `页面文件不存在: ${sourcePage.title}`
849 )
850 }
851 mergeLog('info', 'page:prepare:start', logContext, {
852 sourcePageId: sourcePage.id,
853 sourcePageNumber: sourcePage.pageNumber,
854 targetPageId: nextPageId,
855 targetPageNumber: nextPageNumber
856 })
857 const sourceHtml = await fs.promises.readFile(sourceHtmlPath, 'utf-8')
858 const pageFontProfile = preserveFonts
859 ? (extractMergePageFontProfile(sourceHtml) ?? targetFontProfile)
860 : targetFontProfile
861 const resourcePathMap = await copyPageResources({
862 html: sourceHtml,
863 sourceProjectDir,
864 tempProjectDir: tempRoot,
865 batchId,
866 nextPageId,
867 targetBodyFont: pageFontProfile.bodyFont,
868 preserveFonts
869 })
870 const rewrittenHtml = rewriteMergedPageHtml({
871 html: sourceHtml,
872 oldPageId: sourcePage.pageId,
873 nextPageId,
874 resourcePathMap,
875 targetFontProfile: pageFontProfile
876 })
877 const validation = validatePersistedPageHtml(rewrittenHtml, nextPageId)
878 if (!validation.valid) {
879 throw new PageMergeError(
880 'PAGE_MERGE_PAGE_COPY_FAILED',
881 `页面“${sourcePage.title}”复制失败: ${validation.errors.join('; ')}`
882 )
883 }
884 const targetHtmlPath = path.join(targetData.projectDir, `${nextPageId}.html`)
885 const tempHtmlPath = path.join(tempRoot, `${nextPageId}.html`)
886 await fs.promises.writeFile(tempHtmlPath, rewrittenHtml, 'utf-8')
887 const sourceSkeleton = skeletonByPageNumber.get(sourcePage.pageNumber)
888 const targetSourceDocumentPath = await prepareSourceDocument({
889 skeleton: sourceSkeleton,
890 sourceProjectDir,
891 tempProjectDir: tempRoot,
892 batchId,
893 nextPageId
894 })
895 mergeLog('info', 'page:prepare:completed', logContext, {
896 sourcePageId: sourcePage.id,
897 sourcePageNumber: sourcePage.pageNumber,
898 targetPageId: nextPageId,
899 targetPageNumber: nextPageNumber,
900 copiedResourceCount: resourcePathMap.size,
901 sourceDocumentCopied: Boolean(
902 targetSourceDocumentPath?.startsWith('/docs/merged-pages/')
903 ),
904 durationMs: Date.now() - pageStartedAt
905 })
906 return {
907 page: {
908 id: nextEntityId,
909 pageNumber: nextPageNumber,
910 pageId: nextPageId,
911 title: sourcePage.title,
912 contentOutline: sourcePage.contentOutline,
913 htmlPath: targetHtmlPath,
914 html: rewrittenHtml,
915 status: 'completed' as const,
916 error: null
917 },
918 sourceSkeleton,
919 targetSourceDocumentPath
920 }
921 }
922 )
923 mergeLog('info', 'pages:prepare:completed', logContext, {
924 preparedPageCount: preparedPages.length
925 })
926
927 stage = 'ensure-history-baseline'
928 mergeLog('info', 'history:baseline:start', logContext)
929 await ensureHistoryBaselineSafe(ctx.db, args.targetSessionId, targetData.projectDir)
930 mergeLog('info', 'history:baseline:completed', logContext)
931 stage = 'commit-files'
932 mergeLog('info', 'files:commit:start', logContext, {
933 preparedPageCount: preparedPages.length
934 })
935 await movePreparedEntries(tempRoot, targetData.projectDir, movedTargetFontFiles)
936 mergeLog('info', 'files:commit:completed', logContext, {
937 createdTargetFontFileCount: movedTargetFontFiles.length
938 })
939
940 stage = 'write-page-records'
941 for (const prepared of preparedPages) {
942 await ctx.db.upsertSessionPage({
943 id: prepared.page.id,
944 sessionId: args.targetSessionId,
945 legacyPageId: null,
946 fileSlug: prepared.page.pageId,
947 pageNumber: prepared.page.pageNumber,
948 title: prepared.page.title,
949 htmlPath: prepared.page.htmlPath,
950 status: 'completed',
951 error: null
952 })
953 insertedPageIds.push(prepared.page.id)
954 insertedPageNumbers.push(prepared.page.pageNumber)
955 if (prepared.sourceSkeleton?.source_heading.trim()) {
956 await ctx.db.upsertSourcePageSkeleton({
957 sessionId: args.targetSessionId,
958 pageNumber: prepared.page.pageNumber,
959 title: prepared.page.title,
960 role: prepared.sourceSkeleton.role,
961 sourceDocumentPath:
962 prepared.targetSourceDocumentPath || `merged-session:${args.sourceSessionId}`,
963 sourceDocumentName: prepared.sourceSkeleton.source_document_name,
964 sourceHeading: prepared.sourceSkeleton.source_heading,
965 headingLevel: prepared.sourceSkeleton.heading_level,
966 lineStart: prepared.sourceSkeleton.line_start,
967 lineEnd: prepared.sourceSkeleton.line_end,
968 reason: prepared.sourceSkeleton.reason,
969 confidence: prepared.sourceSkeleton.confidence
970 })
971 }
972 }
973 mergeLog('info', 'database:page-records:completed', logContext, {
974 insertedPageCount: insertedPageIds.length,
975 skeletonCount: preparedPages.filter((item) => item.sourceSkeleton?.source_heading.trim())
976 .length
977 })
978
979 stage = 'persist-deck'
980 const mergedPages = [...targetData.pages, ...preparedPages.map((item) => item.page)]
981 const persistedPages = await persistManagedPages(ctx, {
982 sessionId: args.targetSessionId,
983 projectDir: targetData.projectDir,
984 indexPath: targetData.indexPath,
985 deckTitle: targetData.deckTitle,
986 pages: mergedPages,
987 operation: 'addPage',
988 prompt: `从${sourceType === 'template' ? '模板' : '会话'}《${sourceTitle}》添加 ${preparedPages.length} 页`
989 })
990 if (targetProject?.id) await ctx.db.updateProjectStatus(targetProject.id, 'draft')
991 await ctx.db.updateSessionStatus(args.targetSessionId, 'completed')
992 mergeLog('info', 'deck:persisted', logContext, { totalPageCount: persistedPages.length })
993 stage = 'record-history'
994 mergeLog('info', 'history:commit:start', logContext)
995 await recordHistoryOperationStrict(ctx.db, {
996 sessionId: args.targetSessionId,
997 type: 'addPage',
998 scope: 'session',
999 projectDir: targetData.projectDir,
1000 prompt: `从${sourceType === 'template' ? '模板' : '会话'}《${sourceTitle}》添加 ${preparedPages.length} 页`,
1001 metadata: {
1002 sourceType,
1003 sourceSessionId: sourceId,
1004 sourceSessionTitle: sourceTitle,
1005 sourcePageIds: selectedSourcePages.map((page) => page.id),
1006 sourcePageNumbers: selectedSourcePages.map((page) => page.pageNumber),
1007 insertedPageIds,
1008 insertedPageSlugs: preparedPages.map((item) => item.page.pageId),
1009 mergeBatchId: batchId,
1010 totalPages: persistedPages.length
1011 }
1012 })
1013 mergeLog('info', 'history:commit:completed', logContext)
1014
1015 stage = 'completed'
1016 mergeLog('info', 'request:completed', logContext, {
1017 insertedPageCount: insertedPageIds.length,
1018 totalPageCount: persistedPages.length,
1019 durationMs: Date.now() - startedAt
1020 })
1021 return {
1022 generatedPages: toGeneratedPages(ctx, persistedPages),
1023 insertedPageIds,
1024 selectedPageId: insertedPageIds[0]
1025 }
1026 } catch (error) {
1027 mergeLog('error', 'request:failed', logContext, {
1028 failedStage: stage,
1029 durationMs: Date.now() - startedAt,
1030 code: error instanceof PageMergeError ? error.code : 'PAGE_MERGE_INTERNAL_ERROR',
1031 error: error instanceof Error ? error.message : String(error)
1032 })
1033 const rollbackContext = logContext
1034 mergeLog('warn', 'rollback:start', rollbackContext, {
1035 insertedPageCount: insertedPageIds.length,
1036 preparedPageCount: preparedPages.length
1037 })
1038 await runMergeRollbackStep('delete-session-pages', rollbackContext, () =>
1039 ctx.db.hardDeleteSessionPages(args.targetSessionId, insertedPageIds)
1040 )
1041 await runMergeRollbackStep('delete-source-skeletons', rollbackContext, () =>
1042 ctx.db.deleteSourcePageSkeletons(args.targetSessionId, insertedPageNumbers)
1043 )
1044 await runMergeRollbackStep('restore-page-order', rollbackContext, () =>
1045 ctx.db.replaceSessionPageOrder(
1046 args.targetSessionId,
1047 targetData.pages.map((page) => ({ id: page.id, pageNumber: page.pageNumber }))
1048 )
1049 )
1050 await runMergeRollbackStep('restore-session-metadata', rollbackContext, () =>
1051 ctx.db.updateSessionMetadata(args.targetSessionId, previousMetadata)
1052 )
1053 await runMergeRollbackStep('restore-session-status', rollbackContext, () =>
1054 ctx.db.updateSessionStatus(args.targetSessionId, targetSession.status)
1055 )
1056 if (targetProject?.id) {
1057 await runMergeRollbackStep('restore-project-status', rollbackContext, () =>
1058 ctx.db.updateProjectStatus(targetProject.id, targetProject.status)
1059 )
1060 }
1061 if (previousIndex) {
1062 await runMergeRollbackStep('restore-index', rollbackContext, () =>
1063 fs.promises.writeFile(targetData.indexPath, previousIndex)
1064 )
1065 } else {
1066 await runMergeRollbackStep('remove-created-index', rollbackContext, () =>
1067 fs.promises.rm(targetData.indexPath, { force: true })
1068 )
1069 }
1070 await runMergeRollbackStep('remove-page-html', rollbackContext, () =>
1071 Promise.all(preparedPages.map((item) => fs.promises.rm(item.page.htmlPath, { force: true })))
1072 )
1073 await runMergeRollbackStep('remove-merged-assets', rollbackContext, () =>
1074 fs.promises.rm(path.join(targetData.projectDir, 'assets', 'merged-pages', batchId), {
1075 recursive: true,
1076 force: true
1077 })
1078 )
1079 await runMergeRollbackStep('remove-merged-docs', rollbackContext, () =>
1080 fs.promises.rm(path.join(targetData.projectDir, 'docs', 'merged-pages', batchId), {
1081 recursive: true,
1082 force: true
1083 })
1084 )
1085 await runMergeRollbackStep('remove-created-font-files', rollbackContext, () =>
1086 Promise.all(movedTargetFontFiles.map((fontPath) => fs.promises.rm(fontPath, { force: true })))
1087 )
1088 mergeLog('warn', 'rollback:completed', rollbackContext, {
1089 durationMs: Date.now() - startedAt
1090 })
1091 throw error
1092 } finally {
1093 const cleanupStartedAt = Date.now()
1094 try {
1095 await fs.promises.rm(tempRoot, { recursive: true, force: true })
1096 mergeLog('info', 'cleanup:temp-directory:completed', logContext, {
1097 durationMs: Date.now() - cleanupStartedAt
1098 })
1099 } catch (error) {
1100 mergeLog('warn', 'cleanup:temp-directory:failed', logContext, {
1101 durationMs: Date.now() - cleanupStartedAt,
1102 error: error instanceof Error ? error.message : String(error)
1103 })
1104 }
1105 }
1106 }
1107
1107 lines TYPESCRIPT