| 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 |