| 1 | import fs from 'fs' |
| 2 | import os from 'os' |
| 3 | import path from 'path' |
| 4 | import crypto from 'crypto' |
| 5 | import { unzipSync } from 'fflate' |
| 6 | import log from 'electron-log/main.js' |
| 7 | import type { IpcContext } from '../ipc/context' |
| 8 | import { |
| 9 | buildProjectIndexHtml, |
| 10 | extractPagesDataFromIndex, |
| 11 | type DeckPageFile |
| 12 | } from '../session/template-builder' |
| 13 | import { recordHistoryOperationStrict } from '../history/git-history-service' |
| 14 | import { createSessionMasterIfMissing } from '../session/master-service' |
| 15 | import { createDefaultDesignContract } from '../presentation/design-contract' |
| 16 | import { resolveUsableStyleId } from '../styles/catalog' |
| 17 | import { findSlidePackResourceZipInsideZip } from './slide-pack-archive' |
| 18 | import { |
| 19 | requireSlideSizeFromHtml, |
| 20 | type SlideSizePreset |
| 21 | } from '@shared/slide-size' |
| 22 | |
| 23 | const MAX_IMPORT_FILE_BYTES = 300 * 1024 * 1024 |
| 24 | const MAX_EXTRACTED_BYTES = 600 * 1024 * 1024 |
| 25 | const MAX_EXTRACTED_FILES = 5000 |
| 26 | const IGNORED_IMPORT_FILE_EXTENSIONS = new Set([ |
| 27 | '.ppt', |
| 28 | '.pptx', |
| 29 | '.key', |
| 30 | '.py', |
| 31 | '.pyc', |
| 32 | '.pyo', |
| 33 | '.ipynb', |
| 34 | '.sqlite', |
| 35 | '.sqlite3', |
| 36 | '.db', |
| 37 | '.log' |
| 38 | ]) |
| 39 | const IGNORED_IMPORT_FILE_NAMES = new Set([ |
| 40 | '.ds_store', |
| 41 | '.gitignore', |
| 42 | 'thumbs.db', |
| 43 | 'desktop.ini', |
| 44 | 'package-lock.json', |
| 45 | 'pnpm-lock.yaml', |
| 46 | 'yarn.lock' |
| 47 | ]) |
| 48 | const IGNORED_IMPORT_DIR_NAMES = new Set([ |
| 49 | '.git', |
| 50 | '.github', |
| 51 | '.idea', |
| 52 | '.vscode', |
| 53 | '_export_screenshots', |
| 54 | '__macosx', |
| 55 | '__pycache__', |
| 56 | 'conversation_history', |
| 57 | '.pytest_cache', |
| 58 | '.mypy_cache', |
| 59 | '.ruff_cache', |
| 60 | 'node_modules', |
| 61 | 'tmp', |
| 62 | 'temp', |
| 63 | 'cache', |
| 64 | '.cache' |
| 65 | ]) |
| 66 | |
| 67 | type ImportKind = 'slide-pack' | 'zip' |
| 68 | |
| 69 | type ImportedPage = { |
| 70 | entityId: string |
| 71 | fileSlug: string |
| 72 | legacyPageId: string | null |
| 73 | pageNumber: number |
| 74 | title: string |
| 75 | htmlPath: string |
| 76 | htmlFileName: string |
| 77 | } |
| 78 | |
| 79 | export type SessionFileImportResult = { |
| 80 | success: true |
| 81 | cancelled: false |
| 82 | sessionId: string |
| 83 | title: string |
| 84 | pageCount: number |
| 85 | warnings: string[] |
| 86 | } |
| 87 | |
| 88 | type PreparedImport = { |
| 89 | importKind: ImportKind |
| 90 | sessionRoot: string |
| 91 | warnings: string[] |
| 92 | } |
| 93 | |
| 94 | const isIgnoredArchivePath = ( |
| 95 | relativePath: string, |
| 96 | options?: { allowPresentationFiles?: boolean } |
| 97 | ): boolean => { |
| 98 | const parts = relativePath.split('/').filter(Boolean) |
| 99 | if (parts.length === 0) return true |
| 100 | if (parts.some((part) => IGNORED_IMPORT_DIR_NAMES.has(part.toLowerCase()))) return true |
| 101 | const baseName = parts[parts.length - 1] |
| 102 | if (IGNORED_IMPORT_FILE_NAMES.has(baseName.toLowerCase())) return true |
| 103 | const ext = path.extname(baseName).toLowerCase() |
| 104 | if (options?.allowPresentationFiles && ['.ppt', '.pptx', '.key'].includes(ext)) return false |
| 105 | return IGNORED_IMPORT_FILE_EXTENSIONS.has(ext) |
| 106 | } |
| 107 | |
| 108 | const normalizeArchivePath = (rawName: string): string | null => { |
| 109 | const normalized = rawName.replace(/\\/g, '/').replace(/^\/+/, '') |
| 110 | if (!normalized || normalized.endsWith('/')) return null |
| 111 | const parts = normalized.split('/').filter(Boolean) |
| 112 | if (parts.length === 0 || parts.some((part) => part === '..' || part === '.')) return null |
| 113 | return parts.join('/') |
| 114 | } |
| 115 | |
| 116 | const isPathInside = (targetPath: string, rootPath: string): boolean => { |
| 117 | const relative = path.relative(path.resolve(rootPath), path.resolve(targetPath)) |
| 118 | return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) |
| 119 | } |
| 120 | |
| 121 | const ensureInside = (targetPath: string, rootPath: string, message: string): void => { |
| 122 | if (!isPathInside(targetPath, rootPath)) throw new Error(message) |
| 123 | } |
| 124 | |
| 125 | const tryExtractSlidePackZip = (buffer: Buffer): Uint8Array | null => { |
| 126 | if (buffer.byteLength < 8) return null |
| 127 | let zipLength = 0 |
| 128 | try { |
| 129 | zipLength = Number(buffer.readBigUInt64LE(buffer.byteLength - 8)) |
| 130 | } catch { |
| 131 | return null |
| 132 | } |
| 133 | if (!Number.isSafeInteger(zipLength) || zipLength <= 0) return null |
| 134 | const zipStart = buffer.byteLength - 8 - zipLength |
| 135 | if (zipStart < 0) return null |
| 136 | const zipData = buffer.subarray(zipStart, buffer.byteLength - 8) |
| 137 | if (zipData[0] !== 0x50 || zipData[1] !== 0x4b) return null |
| 138 | return zipData |
| 139 | } |
| 140 | |
| 141 | const tryReadZip = (zipData: Uint8Array): Record<string, Uint8Array> | null => { |
| 142 | try { |
| 143 | return unzipSync(zipData) |
| 144 | } catch { |
| 145 | return null |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | const findSlidePackZipInsideZip = (zipData: Uint8Array): Uint8Array | null => { |
| 150 | const files = tryReadZip(zipData) |
| 151 | if (!files) return null |
| 152 | const candidates = Object.entries(files) |
| 153 | .map(([name, data]) => ({ name: normalizeArchivePath(name), data })) |
| 154 | .filter((entry): entry is { name: string; data: Uint8Array } => { |
| 155 | if (!entry.name || isIgnoredArchivePath(entry.name, { allowPresentationFiles: true })) return false |
| 156 | return entry.data.byteLength > 8 |
| 157 | }) |
| 158 | if (candidates.length !== 1) return null |
| 159 | return tryExtractSlidePackZip(Buffer.from(candidates[0].data)) |
| 160 | } |
| 161 | |
| 162 | const archiveHasRootIndexHtml = (zipData: Uint8Array): boolean => { |
| 163 | const files = tryReadZip(zipData) |
| 164 | if (!files) return false |
| 165 | return Object.keys(files).some((rawName) => { |
| 166 | const relativePath = normalizeArchivePath(rawName) |
| 167 | return relativePath?.toLowerCase() === 'index.html' |
| 168 | }) |
| 169 | } |
| 170 | |
| 171 | const extractZipToDirectory = async ( |
| 172 | zipData: Uint8Array, |
| 173 | targetDir: string, |
| 174 | mode: 'deck-root' | 'single-session-directory' |
| 175 | ): Promise<string> => { |
| 176 | log.info('[session-import] extract zip start', { |
| 177 | targetDir, |
| 178 | mode, |
| 179 | zipBytes: zipData.byteLength |
| 180 | }) |
| 181 | const files = tryReadZip(zipData) |
| 182 | if (!files) throw new Error('无法读取 ZIP 文件,请确认文件未损坏。') |
| 183 | |
| 184 | let totalBytes = 0 |
| 185 | let fileCount = 0 |
| 186 | let skippedFiles = 0 |
| 187 | const entries: Array<{ relativePath: string; data: Uint8Array }> = [] |
| 188 | const rootNames = new Set<string>() |
| 189 | const illegalRootFiles: string[] = [] |
| 190 | |
| 191 | for (const [rawName, data] of Object.entries(files)) { |
| 192 | const relativePath = normalizeArchivePath(rawName) |
| 193 | if (!relativePath) continue |
| 194 | if (isIgnoredArchivePath(relativePath)) { |
| 195 | skippedFiles += 1 |
| 196 | continue |
| 197 | } |
| 198 | fileCount += 1 |
| 199 | totalBytes += data.byteLength |
| 200 | if (fileCount > MAX_EXTRACTED_FILES) throw new Error('导入包文件数量过多,请精简后重试。') |
| 201 | if (totalBytes > MAX_EXTRACTED_BYTES) throw new Error('导入包解压后体积过大,请精简素材后重试。') |
| 202 | |
| 203 | const parts = relativePath.split('/') |
| 204 | if (mode === 'single-session-directory') { |
| 205 | if (parts.length < 2) { |
| 206 | illegalRootFiles.push(relativePath) |
| 207 | continue |
| 208 | } |
| 209 | rootNames.add(parts[0]) |
| 210 | } |
| 211 | entries.push({ relativePath, data }) |
| 212 | } |
| 213 | |
| 214 | if (entries.length === 0) throw new Error('导入包为空或不包含可导入的会话文件。') |
| 215 | if (mode === 'single-session-directory') { |
| 216 | if (illegalRootFiles.length > 0 || rootNames.size !== 1) { |
| 217 | log.warn('[session-import] invalid zip root layout', { |
| 218 | rootCount: rootNames.size, |
| 219 | rootNames: Array.from(rootNames), |
| 220 | illegalRootFiles: illegalRootFiles.slice(0, 20) |
| 221 | }) |
| 222 | throw new Error('ZIP 根目录必须只包含一个完整会话目录,请压缩 session-id 文件夹后再导入。') |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | await fs.promises.mkdir(targetDir, { recursive: true }) |
| 227 | for (const entry of entries) { |
| 228 | const targetPath = path.resolve(targetDir, entry.relativePath) |
| 229 | ensureInside(targetPath, targetDir, '压缩包包含非法路径,已拒绝导入。') |
| 230 | await fs.promises.mkdir(path.dirname(targetPath), { recursive: true }) |
| 231 | await fs.promises.writeFile(targetPath, entry.data) |
| 232 | } |
| 233 | |
| 234 | const sessionRoot = mode === 'single-session-directory' |
| 235 | ? path.join(targetDir, Array.from(rootNames)[0]) |
| 236 | : targetDir |
| 237 | log.info('[session-import] extract zip completed', { |
| 238 | targetDir, |
| 239 | mode, |
| 240 | sessionRoot, |
| 241 | fileCount, |
| 242 | totalBytes, |
| 243 | skippedFiles |
| 244 | }) |
| 245 | return sessionRoot |
| 246 | } |
| 247 | |
| 248 | const prepareImportSource = async (sourceBuffer: Buffer, tempDir: string): Promise<PreparedImport> => { |
| 249 | log.info('[session-import] detect source start', { |
| 250 | bytes: sourceBuffer.byteLength, |
| 251 | tempDir |
| 252 | }) |
| 253 | const directSlidePackZip = tryExtractSlidePackZip(sourceBuffer) |
| 254 | if (directSlidePackZip) { |
| 255 | log.info('[session-import] detected direct slide-pack', { |
| 256 | zipBytes: directSlidePackZip.byteLength |
| 257 | }) |
| 258 | return { |
| 259 | importKind: 'slide-pack', |
| 260 | sessionRoot: await extractZipToDirectory(directSlidePackZip, path.join(tempDir, 'slide-pack'), 'deck-root'), |
| 261 | warnings: [] |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | const appBundleSlidePackZip = findSlidePackResourceZipInsideZip(sourceBuffer) |
| 266 | if (appBundleSlidePackZip) { |
| 267 | log.info('[session-import] detected macOS app slide-pack', { |
| 268 | zipBytes: appBundleSlidePackZip.byteLength |
| 269 | }) |
| 270 | return { |
| 271 | importKind: 'slide-pack', |
| 272 | sessionRoot: await extractZipToDirectory( |
| 273 | appBundleSlidePackZip, |
| 274 | path.join(tempDir, 'slide-pack-app'), |
| 275 | 'deck-root' |
| 276 | ), |
| 277 | warnings: [] |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | const nestedSlidePackZip = findSlidePackZipInsideZip(sourceBuffer) |
| 282 | if (nestedSlidePackZip) { |
| 283 | log.info('[session-import] detected zipped slide-pack', { |
| 284 | zipBytes: nestedSlidePackZip.byteLength |
| 285 | }) |
| 286 | return { |
| 287 | importKind: 'slide-pack', |
| 288 | sessionRoot: await extractZipToDirectory( |
| 289 | nestedSlidePackZip, |
| 290 | path.join(tempDir, 'slide-pack-zip'), |
| 291 | 'deck-root' |
| 292 | ), |
| 293 | warnings: [] |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | if (archiveHasRootIndexHtml(sourceBuffer)) { |
| 298 | log.info('[session-import] detected flat session zip') |
| 299 | return { |
| 300 | importKind: 'zip', |
| 301 | sessionRoot: await extractZipToDirectory( |
| 302 | sourceBuffer, |
| 303 | path.join(tempDir, 'session-zip-root'), |
| 304 | 'deck-root' |
| 305 | ), |
| 306 | warnings: [] |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | log.info('[session-import] fallback to standard session zip') |
| 311 | return { |
| 312 | importKind: 'zip', |
| 313 | sessionRoot: await extractZipToDirectory( |
| 314 | sourceBuffer, |
| 315 | path.join(tempDir, 'session-zip'), |
| 316 | 'single-session-directory' |
| 317 | ), |
| 318 | warnings: [] |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | const sanitizeTitle = (title: string, fallback: string): string => { |
| 323 | const normalized = title.replace(/\s*[·-]\s*Preview\s*$/i, '').trim() |
| 324 | return normalized.slice(0, 120) || fallback |
| 325 | } |
| 326 | |
| 327 | const readTitleFromIndex = async (indexPath: string, fallback: string): Promise<string> => { |
| 328 | try { |
| 329 | const html = await fs.promises.readFile(indexPath, 'utf-8') |
| 330 | const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i) |
| 331 | if (!match?.[1]) return fallback |
| 332 | return sanitizeTitle(match[1].replace(/<[^>]+>/g, '').trim(), fallback) |
| 333 | } catch { |
| 334 | return fallback |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | const assertValidFileSlug = (value: string): string => { |
| 339 | const trimmed = value.trim() |
| 340 | if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) { |
| 341 | throw new Error(`页面 ID 不合法:${value}`) |
| 342 | } |
| 343 | return trimmed |
| 344 | } |
| 345 | |
| 346 | const validateSessionRoot = async ( |
| 347 | sessionRoot: string |
| 348 | ): Promise<Array<{ pageNumber: number; fileSlug: string; title: string; htmlFileName: string }>> => { |
| 349 | log.info('[session-import] validate session root start', { sessionRoot }) |
| 350 | const indexPath = path.join(sessionRoot, 'index.html') |
| 351 | if (!fs.existsSync(indexPath)) throw new Error('会话目录缺少 index.html。') |
| 352 | const indexHtml = await fs.promises.readFile(indexPath, 'utf-8') |
| 353 | const pagesData = extractPagesDataFromIndex(indexHtml) |
| 354 | if (pagesData.length === 0) { |
| 355 | throw new Error('index.html 缺少页面清单,无法导入。') |
| 356 | } |
| 357 | |
| 358 | const seenSlugs = new Set<string>() |
| 359 | const pages = pagesData.map((page, index) => { |
| 360 | const fileSlug = assertValidFileSlug(page.pageId || `page-${index + 1}`) |
| 361 | if (seenSlugs.has(fileSlug)) throw new Error(`页面 ID 重复:${fileSlug}`) |
| 362 | seenSlugs.add(fileSlug) |
| 363 | const htmlFileName = page.htmlPath || `${fileSlug}.html` |
| 364 | if (path.isAbsolute(htmlFileName) || htmlFileName.includes('\\')) { |
| 365 | throw new Error(`页面路径不合法:${htmlFileName}`) |
| 366 | } |
| 367 | const htmlPath = path.resolve(sessionRoot, htmlFileName) |
| 368 | ensureInside(htmlPath, sessionRoot, '页面文件路径越界,已拒绝导入。') |
| 369 | if (!htmlPath.toLowerCase().endsWith('.html')) throw new Error(`页面文件不是 HTML:${htmlFileName}`) |
| 370 | if (!fs.existsSync(htmlPath)) throw new Error(`页面文件缺失:${htmlFileName}`) |
| 371 | return { |
| 372 | pageNumber: Number(page.pageNumber) > 0 ? Math.floor(Number(page.pageNumber)) : index + 1, |
| 373 | fileSlug, |
| 374 | title: page.title || `Page ${index + 1}`, |
| 375 | htmlFileName |
| 376 | } |
| 377 | }) |
| 378 | |
| 379 | const sortedPages = pages.sort((left, right) => left.pageNumber - right.pageNumber) |
| 380 | log.info('[session-import] validate session root completed', { |
| 381 | sessionRoot, |
| 382 | pageCount: sortedPages.length, |
| 383 | pages: sortedPages.map((page) => ({ |
| 384 | pageNumber: page.pageNumber, |
| 385 | fileSlug: page.fileSlug, |
| 386 | htmlFileName: page.htmlFileName |
| 387 | })) |
| 388 | }) |
| 389 | return sortedPages |
| 390 | } |
| 391 | |
| 392 | const copyDirectory = async (sourceDir: string, targetDir: string): Promise<void> => { |
| 393 | log.info('[session-import] copy directory start', { sourceDir, targetDir }) |
| 394 | const sourceRoot = path.resolve(sourceDir) |
| 395 | const targetRoot = path.resolve(targetDir) |
| 396 | let copiedFiles = 0 |
| 397 | let skippedFiles = 0 |
| 398 | const copyRecursive = async (currentSource: string): Promise<void> => { |
| 399 | const entries = await fs.promises.readdir(currentSource, { withFileTypes: true }) |
| 400 | for (const entry of entries) { |
| 401 | const sourcePath = path.join(currentSource, entry.name) |
| 402 | const relativePath = path.relative(sourceRoot, sourcePath).split(path.sep).join('/') |
| 403 | if (!relativePath) continue |
| 404 | if (isIgnoredArchivePath(relativePath)) { |
| 405 | skippedFiles += 1 |
| 406 | continue |
| 407 | } |
| 408 | const targetPath = path.resolve(targetRoot, relativePath) |
| 409 | ensureInside(targetPath, targetRoot, '复制目标路径不合法,已拒绝导入。') |
| 410 | if (entry.isSymbolicLink()) continue |
| 411 | if (entry.isDirectory()) { |
| 412 | await fs.promises.mkdir(targetPath, { recursive: true }) |
| 413 | await copyRecursive(sourcePath) |
| 414 | } else if (entry.isFile()) { |
| 415 | await fs.promises.mkdir(path.dirname(targetPath), { recursive: true }) |
| 416 | await fs.promises.copyFile(sourcePath, targetPath) |
| 417 | copiedFiles += 1 |
| 418 | } |
| 419 | } |
| 420 | } |
| 421 | await fs.promises.mkdir(targetRoot, { recursive: true }) |
| 422 | await copyRecursive(sourceRoot) |
| 423 | log.info('[session-import] copy directory completed', { |
| 424 | sourceDir, |
| 425 | targetDir, |
| 426 | copiedFiles, |
| 427 | skippedFiles |
| 428 | }) |
| 429 | } |
| 430 | |
| 431 | const buildImportedPages = ( |
| 432 | projectDir: string, |
| 433 | pages: Array<{ pageNumber: number; fileSlug: string; title: string; htmlFileName: string }> |
| 434 | ): ImportedPage[] => |
| 435 | pages.map((page) => ({ |
| 436 | entityId: crypto.randomUUID(), |
| 437 | fileSlug: page.fileSlug, |
| 438 | legacyPageId: /^page-\d+$/i.test(page.fileSlug) ? page.fileSlug : null, |
| 439 | pageNumber: page.pageNumber, |
| 440 | title: page.title, |
| 441 | htmlPath: path.join(projectDir, page.htmlFileName), |
| 442 | htmlFileName: page.htmlFileName |
| 443 | })) |
| 444 | |
| 445 | const rewriteIndexHtml = async ( |
| 446 | projectDir: string, |
| 447 | title: string, |
| 448 | pages: ImportedPage[], |
| 449 | slideSize: SlideSizePreset |
| 450 | ): Promise<void> => { |
| 451 | log.info('[session-import] rewrite index start', { |
| 452 | projectDir, |
| 453 | title, |
| 454 | pageCount: pages.length |
| 455 | }) |
| 456 | const deckPages: DeckPageFile[] = pages.map((page) => ({ |
| 457 | id: page.entityId, |
| 458 | pageNumber: page.pageNumber, |
| 459 | pageId: page.fileSlug, |
| 460 | title: page.title, |
| 461 | htmlPath: page.htmlFileName |
| 462 | })) |
| 463 | await fs.promises.writeFile( |
| 464 | path.join(projectDir, 'index.html'), |
| 465 | buildProjectIndexHtml(title, deckPages, slideSize), |
| 466 | 'utf-8' |
| 467 | ) |
| 468 | log.info('[session-import] rewrite index completed', { |
| 469 | projectDir, |
| 470 | pageCount: pages.length |
| 471 | }) |
| 472 | } |
| 473 | |
| 474 | const assertImportedSessionReady = async ( |
| 475 | ctx: IpcContext, |
| 476 | args: { sessionId: string; projectDir: string; pageCount: number } |
| 477 | ): Promise<void> => { |
| 478 | log.info('[session-import] final validation start', { |
| 479 | sessionId: args.sessionId, |
| 480 | projectDir: args.projectDir, |
| 481 | pageCount: args.pageCount |
| 482 | }) |
| 483 | const session = await ctx.db.getSession(args.sessionId) |
| 484 | const project = await ctx.db.getProject(args.sessionId) |
| 485 | const pages = await ctx.db.listSessionPages(args.sessionId) |
| 486 | const run = await ctx.db.getLatestGenerationRun(args.sessionId) |
| 487 | const history = await ctx.db.listSessionOperations(args.sessionId, { limit: 1 }) |
| 488 | const currentCommit = typeof session?.currentCommit === 'string' ? session.currentCommit : '' |
| 489 | if (session?.status !== 'completed') throw new Error('导入校验失败:会话状态未完成。') |
| 490 | if (path.resolve(project?.root_path || '') !== path.resolve(args.projectDir)) { |
| 491 | throw new Error('导入校验失败:项目目录未正确写入。') |
| 492 | } |
| 493 | if (pages.length !== args.pageCount) throw new Error('导入校验失败:页面数量不一致。') |
| 494 | if (!pages.every((page) => page.status === 'completed' && fs.existsSync(page.html_path))) { |
| 495 | throw new Error('导入校验失败:页面文件不完整。') |
| 496 | } |
| 497 | if (run?.mode !== 'import' || run.status !== 'completed') { |
| 498 | throw new Error('导入校验失败:导入运行记录不完整。') |
| 499 | } |
| 500 | if (history[0]?.type !== 'import' || !currentCommit) { |
| 501 | throw new Error('导入校验失败:历史起点未正确创建。') |
| 502 | } |
| 503 | log.info('[session-import] final validation completed', { |
| 504 | sessionId: args.sessionId, |
| 505 | projectId: project?.id, |
| 506 | runId: run.id, |
| 507 | operationId: history[0]?.id, |
| 508 | currentCommit |
| 509 | }) |
| 510 | } |
| 511 | |
| 512 | export async function importSessionFile( |
| 513 | ctx: IpcContext, |
| 514 | sourcePath: string |
| 515 | ): Promise<SessionFileImportResult> { |
| 516 | const sourceStat = await fs.promises.stat(sourcePath) |
| 517 | if (!sourceStat.isFile()) throw new Error('请选择一个会话导入文件。') |
| 518 | if (sourceStat.size > MAX_IMPORT_FILE_BYTES) { |
| 519 | throw new Error('导入文件不能超过 300MB。') |
| 520 | } |
| 521 | |
| 522 | const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ohmyppt-session-import-')) |
| 523 | const sessionId = crypto.randomUUID() |
| 524 | const storagePath = await ctx.resolveStoragePath() |
| 525 | const projectDir = path.join(storagePath, sessionId) |
| 526 | const originalFileName = path.basename(sourcePath) |
| 527 | const fallbackTitle = sanitizeTitle(path.basename(originalFileName, path.extname(originalFileName)), '导入的会话') |
| 528 | |
| 529 | log.info('[session-import] import start', { |
| 530 | sessionId, |
| 531 | sourcePath, |
| 532 | originalFileName, |
| 533 | sourceBytes: sourceStat.size, |
| 534 | tempDir, |
| 535 | projectDir |
| 536 | }) |
| 537 | |
| 538 | try { |
| 539 | const sourceBuffer = await fs.promises.readFile(sourcePath) |
| 540 | const prepared = await prepareImportSource(sourceBuffer, tempDir) |
| 541 | log.info('[session-import] source prepared', { |
| 542 | sessionId, |
| 543 | importKind: prepared.importKind, |
| 544 | sessionRoot: prepared.sessionRoot, |
| 545 | warningCount: prepared.warnings.length |
| 546 | }) |
| 547 | const sourcePages = await validateSessionRoot(prepared.sessionRoot) |
| 548 | const title = await readTitleFromIndex(path.join(prepared.sessionRoot, 'index.html'), fallbackTitle) |
| 549 | log.info('[session-import] title resolved', { |
| 550 | sessionId, |
| 551 | title, |
| 552 | fallbackTitle |
| 553 | }) |
| 554 | |
| 555 | await copyDirectory(prepared.sessionRoot, projectDir) |
| 556 | await ctx.ensureSessionAssets(projectDir) |
| 557 | await createSessionMasterIfMissing(projectDir) |
| 558 | await fs.promises.rm(path.join(projectDir, '.git'), { recursive: true, force: true }) |
| 559 | log.info('[session-import] project files ready', { |
| 560 | sessionId, |
| 561 | projectDir, |
| 562 | removedGitDir: true |
| 563 | }) |
| 564 | |
| 565 | const importedPages = buildImportedPages(projectDir, sourcePages) |
| 566 | const sourceSizeHtml = await fs.promises |
| 567 | .readFile(path.join(projectDir, 'index.html'), 'utf-8') |
| 568 | .catch(async () => |
| 569 | importedPages[0] |
| 570 | ? fs.promises.readFile(importedPages[0].htmlPath, 'utf-8').catch(() => '') |
| 571 | : '' |
| 572 | ) |
| 573 | const slideSize = requireSlideSizeFromHtml(sourceSizeHtml) |
| 574 | await rewriteIndexHtml(projectDir, title, importedPages, slideSize) |
| 575 | const styleId = resolveUsableStyleId() |
| 576 | |
| 577 | const metadata: Record<string, unknown> = { |
| 578 | source: 'session-file-import', |
| 579 | importedAt: Date.now(), |
| 580 | originalFileName, |
| 581 | importKind: prepared.importKind, |
| 582 | entryMode: 'multi_page', |
| 583 | indexPath: path.join(projectDir, 'index.html'), |
| 584 | warnings: prepared.warnings |
| 585 | } |
| 586 | |
| 587 | log.info('[session-import] db write start', { |
| 588 | sessionId, |
| 589 | title, |
| 590 | pageCount: importedPages.length, |
| 591 | slideSizeId: slideSize.id, |
| 592 | slideWidth: slideSize.width, |
| 593 | slideHeight: slideSize.height, |
| 594 | importKind: prepared.importKind |
| 595 | }) |
| 596 | await ctx.db.createSession({ |
| 597 | id: sessionId, |
| 598 | title, |
| 599 | topic: title, |
| 600 | styleId, |
| 601 | pageCount: importedPages.length, |
| 602 | slideSizeId: slideSize.id, |
| 603 | slideWidth: slideSize.width, |
| 604 | slideHeight: slideSize.height, |
| 605 | provider: 'import', |
| 606 | model: 'session-file-import' |
| 607 | }) |
| 608 | await ctx.db.updateSessionDesignContract(sessionId, createDefaultDesignContract()) |
| 609 | const projectId = await ctx.db.createProject({ |
| 610 | session_id: sessionId, |
| 611 | title, |
| 612 | output_path: projectDir, |
| 613 | root_path: projectDir |
| 614 | }) |
| 615 | metadata.projectId = projectId |
| 616 | log.info('[session-import] project row created', { |
| 617 | sessionId, |
| 618 | projectId, |
| 619 | projectDir |
| 620 | }) |
| 621 | const runId = await ctx.db.createGenerationRun({ |
| 622 | sessionId, |
| 623 | mode: 'import', |
| 624 | totalPages: importedPages.length, |
| 625 | metadata: { |
| 626 | source: 'session-file-import', |
| 627 | originalFileName, |
| 628 | importKind: prepared.importKind |
| 629 | } |
| 630 | }) |
| 631 | log.info('[session-import] generation run created', { |
| 632 | sessionId, |
| 633 | runId, |
| 634 | pageCount: importedPages.length |
| 635 | }) |
| 636 | |
| 637 | for (const page of importedPages) { |
| 638 | log.info('[session-import] upsert page', { |
| 639 | sessionId, |
| 640 | runId, |
| 641 | pageNumber: page.pageNumber, |
| 642 | fileSlug: page.fileSlug, |
| 643 | entityId: page.entityId, |
| 644 | htmlPath: page.htmlPath |
| 645 | }) |
| 646 | await ctx.db.upsertGenerationPage({ |
| 647 | runId, |
| 648 | sessionId, |
| 649 | pageId: page.fileSlug, |
| 650 | pageNumber: page.pageNumber, |
| 651 | title: page.title, |
| 652 | contentOutline: '', |
| 653 | layoutIntent: null, |
| 654 | htmlPath: page.htmlPath, |
| 655 | status: 'completed' |
| 656 | }) |
| 657 | await ctx.db.upsertSessionPage({ |
| 658 | id: page.entityId, |
| 659 | sessionId, |
| 660 | legacyPageId: page.legacyPageId, |
| 661 | fileSlug: page.fileSlug, |
| 662 | pageNumber: page.pageNumber, |
| 663 | title: page.title, |
| 664 | htmlPath: page.htmlPath, |
| 665 | status: 'completed', |
| 666 | error: null |
| 667 | }) |
| 668 | } |
| 669 | |
| 670 | await ctx.db.updateGenerationRunStatus(runId, 'completed') |
| 671 | await ctx.db.updateSessionMetadata(sessionId, metadata) |
| 672 | await ctx.db.updateProjectStatus(projectId, 'draft') |
| 673 | await ctx.db.updateSessionStatus(sessionId, 'completed') |
| 674 | log.info('[session-import] db write completed', { |
| 675 | sessionId, |
| 676 | projectId, |
| 677 | runId |
| 678 | }) |
| 679 | |
| 680 | log.info('[session-import] history baseline start', { |
| 681 | sessionId, |
| 682 | projectDir, |
| 683 | runId |
| 684 | }) |
| 685 | await recordHistoryOperationStrict(ctx.db, { |
| 686 | sessionId, |
| 687 | projectDir, |
| 688 | type: 'import', |
| 689 | scope: 'session', |
| 690 | prompt: `导入会话文件:${originalFileName}`, |
| 691 | metadata: { |
| 692 | runId, |
| 693 | source: 'session-file-import', |
| 694 | importKind: prepared.importKind, |
| 695 | originalFileName, |
| 696 | pageCount: importedPages.length, |
| 697 | sessionMetadata: metadata |
| 698 | } |
| 699 | }) |
| 700 | log.info('[session-import] history baseline completed', { |
| 701 | sessionId, |
| 702 | projectDir |
| 703 | }) |
| 704 | |
| 705 | await assertImportedSessionReady(ctx, { |
| 706 | sessionId, |
| 707 | projectDir, |
| 708 | pageCount: importedPages.length |
| 709 | }) |
| 710 | |
| 711 | log.info('[session-import] completed', { |
| 712 | sessionId, |
| 713 | projectDir, |
| 714 | pageCount: importedPages.length, |
| 715 | originalFileName, |
| 716 | importKind: prepared.importKind |
| 717 | }) |
| 718 | |
| 719 | return { |
| 720 | success: true, |
| 721 | cancelled: false, |
| 722 | sessionId, |
| 723 | title, |
| 724 | pageCount: importedPages.length, |
| 725 | warnings: prepared.warnings |
| 726 | } |
| 727 | } catch (error) { |
| 728 | log.error('[session-import] import failed', { |
| 729 | sessionId, |
| 730 | sourcePath, |
| 731 | projectDir, |
| 732 | message: error instanceof Error ? error.message : String(error) |
| 733 | }) |
| 734 | await ctx.db.deleteSession(sessionId).catch((cleanupError) => { |
| 735 | log.warn('[session-import] cleanup db failed', { |
| 736 | sessionId, |
| 737 | message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError) |
| 738 | }) |
| 739 | }) |
| 740 | await fs.promises.rm(projectDir, { recursive: true, force: true }).catch((cleanupError) => { |
| 741 | log.warn('[session-import] cleanup project dir failed', { |
| 742 | sessionId, |
| 743 | projectDir, |
| 744 | message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError) |
| 745 | }) |
| 746 | }) |
| 747 | throw error |
| 748 | } finally { |
| 749 | await fs.promises.rm(tempDir, { recursive: true, force: true }).catch((cleanupError) => { |
| 750 | log.warn('[session-import] cleanup temp dir failed', { |
| 751 | sessionId, |
| 752 | tempDir, |
| 753 | message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError) |
| 754 | }) |
| 755 | }) |
| 756 | log.info('[session-import] import finished', { |
| 757 | sessionId, |
| 758 | tempDir |
| 759 | }) |
| 760 | } |
| 761 | } |
| 762 |