返回 oh-my-ppt
session-project.ts
根目录 / src / main / ipc / runtime / session-project.ts
1 import fs from 'fs'
2 import path from 'path'
3 import { pathToFileURL } from 'url'
4 import type { PPTDatabase } from '../../db/database'
5
6 export type SessionGenerationSnapshot = {
7 session: Record<string, unknown> | null | undefined
8 pages: Array<{
9 pageNumber: number
10 title: string
11 html: string
12 htmlPath?: string
13 pageId?: string
14 sourceUrl?: string
15 status?: string
16 error?: string | null
17 }>
18 }
19
20 export type SessionProjectResolver = {
21 getPageSourceUrl(htmlPath?: string): string | undefined
22 validateProjectIndexHtml(html: string): string[]
23 parseSessionMetadataObject(value: unknown): Record<string, unknown>
24 buildSessionGenerationSnapshot(
25 session: Record<string, unknown> | null | undefined,
26 options?: { includeHtml?: boolean }
27 ): Promise<SessionGenerationSnapshot>
28 isPathInside(targetPath: string, rootPath: string): boolean
29 resolveProjectPageHtmlPath(
30 projectDir: string,
31 fileSlug: string,
32 candidatePath?: string | null
33 ): string
34 toSafeAssetBaseName(value: string): string
35 resolveSessionProjectDir(sessionId: string): Promise<string>
36 }
37
38 export function createSessionProjectResolver(args: {
39 db: PPTDatabase
40 }): SessionProjectResolver {
41 const { db } = args
42
43 const getPageSourceUrl = (htmlPath?: string): string | undefined => {
44 if (!htmlPath || !fs.existsSync(htmlPath)) return undefined
45 return pathToFileURL(htmlPath).toString()
46 }
47
48 const validateProjectIndexHtml = (html: string): string[] => {
49 const errors: string[] = []
50 if (!/<html[\s>]/i.test(html)) errors.push('index.html 缺少 <html> 标签')
51 if (!/<body[\s>]/i.test(html)) errors.push('index.html 缺少 <body> 标签')
52 if (!/<iframe\b[^>]*class=["'][^"']*\bppt-preview-frame\b/i.test(html)) {
53 errors.push('index.html 缺少页面预览 iframe')
54 }
55 if (!/id=["']pages-data["']/i.test(html)) {
56 errors.push('index.html 缺少 pages-data 页面数据')
57 }
58 const hasInlineJs =
59 /const\s+pages\s*=\s*JSON\.parse/i.test(html) && /function\s+applyPage\s*\(/i.test(html)
60 const hasExternalRuntime = /src=["'][^"']*index-runtime\.js["']/i.test(html)
61 if (!hasInlineJs && !hasExternalRuntime) {
62 errors.push('index.html 缺少页面数据解析逻辑')
63 }
64 return errors
65 }
66
67 const parseSessionMetadataObject = (value: unknown): Record<string, unknown> => {
68 if (typeof value !== 'string' || value.trim().length === 0) return {}
69 try {
70 const parsed = JSON.parse(value) as unknown
71 return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
72 ? (parsed as Record<string, unknown>)
73 : {}
74 } catch {
75 return {}
76 }
77 }
78
79 const isPathInside = (targetPath: string, rootPath: string): boolean => {
80 const relative = path.relative(rootPath, targetPath)
81 return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
82 }
83
84 const resolveProjectPageHtmlPath = (
85 projectDir: string,
86 fileSlug: string,
87 candidatePath?: string | null
88 ): string => {
89 const projectRoot = path.resolve(projectDir)
90 const fallbackPath = path.resolve(projectRoot, `${fileSlug}.html`)
91 const rawCandidate = typeof candidatePath === 'string' ? candidatePath.trim() : ''
92 if (!rawCandidate) return fallbackPath
93 const resolvedCandidate = path.isAbsolute(rawCandidate)
94 ? path.resolve(rawCandidate)
95 : path.resolve(projectRoot, rawCandidate)
96 if (!isPathInside(resolvedCandidate, projectRoot)) return fallbackPath
97 return fs.existsSync(resolvedCandidate) ? resolvedCandidate : fallbackPath
98 }
99
100 const toSafeAssetBaseName = (value: string): string => {
101 const parsed = path.parse(value)
102 const fallback = parsed.name || 'image'
103 const safe = fallback
104 .normalize('NFKD')
105 .replace(/[^\w\u4e00-\u9fff.-]+/g, '-')
106 .replace(/^-+|-+$/g, '')
107 .slice(0, 72)
108 return safe || 'image'
109 }
110
111 const resolveSessionProjectDir = async (sessionId: string): Promise<string> => {
112 const session = await db.getSession(sessionId)
113 if (!session) throw new Error('Session not found')
114 const project = await db.getProject(sessionId)
115 const rootPath = typeof project?.root_path === 'string' ? project.root_path.trim() : ''
116 if (!rootPath) throw new Error(`Session ${sessionId} has no root_path`)
117 return path.resolve(rootPath)
118 }
119
120 const buildSessionGenerationSnapshot = async (
121 session: Record<string, unknown> | null | undefined,
122 options?: { includeHtml?: boolean }
123 ): Promise<SessionGenerationSnapshot> => {
124 if (!session) return { session, pages: [] }
125 const sessionId = String(session.id || '').trim()
126 if (!sessionId) return { session, pages: [] }
127
128 const metadata = parseSessionMetadataObject(session.metadata)
129 const sessionPages = await db.listSessionPages(sessionId)
130 if (sessionPages.length === 0) return { session, pages: [] }
131
132 const projectDir = await resolveSessionProjectDir(sessionId)
133 const project = await db.getProject(sessionId)
134 const indexPath = path.join(projectDir, 'index.html')
135 const pages: SessionGenerationSnapshot['pages'] = []
136
137 for (const page of sessionPages) {
138 const pageId = page.file_slug
139 const title = page.title || `第 ${page.page_number} 页`
140 const htmlPath = resolveProjectPageHtmlPath(projectDir, pageId, page.html_path)
141 const html =
142 options?.includeHtml && fs.existsSync(htmlPath)
143 ? await fs.promises.readFile(htmlPath, 'utf-8')
144 : ''
145 pages.push({
146 pageNumber: page.page_number,
147 title,
148 html: options?.includeHtml ? html : '',
149 htmlPath,
150 pageId,
151 sourceUrl: getPageSourceUrl(htmlPath),
152 status: page.status,
153 error: page.error
154 })
155 }
156
157 const synthesizedMetadata = {
158 ...metadata,
159 entryMode: 'multi_page',
160 indexPath,
161 projectId: project?.id || metadata.projectId
162 }
163 const completedCount = pages.filter((page) => page.status === 'completed').length
164 const failedCount = pages.filter((page) => page.status === 'failed').length
165
166 return {
167 session: {
168 ...session,
169 metadata: JSON.stringify(synthesizedMetadata),
170 page_count: pages.length,
171 generated_count: completedCount,
172 failed_count: failedCount
173 },
174 pages: pages.sort((a, b) => a.pageNumber - b.pageNumber)
175 }
176 }
177
178 return {
179 getPageSourceUrl,
180 validateProjectIndexHtml,
181 parseSessionMetadataObject,
182 buildSessionGenerationSnapshot,
183 isPathInside,
184 resolveProjectPageHtmlPath,
185 toSafeAssetBaseName,
186 resolveSessionProjectDir
187 }
188 }
189
189 lines TYPESCRIPT