返回 oh-my-ppt
session-importer.ts
根目录 / src / main / session-import / session-importer.ts
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
762 lines TYPESCRIPT