返回 oh-my-ppt
master-mutation-service.ts
根目录 / src / main / session / master-mutation-service.ts
1 import crypto from 'crypto'
2 import fs from 'fs'
3 import path from 'path'
4 import type { SessionPageRecord } from '../db/database'
5 import { GitHistoryService } from '../history/git-history-service'
6 import {
7 ensureMasterStyleLink,
8 hasUniqueMasterStyleLink,
9 isMasterElementsDisabled,
10 setMasterElementsDisabled,
11 setMasterPageNumber
12 } from '../presentation/html/master-link'
13 import {
14 copyProjectFontResources,
15 resolveProjectFontResources
16 } from '../presentation/fonts/font-registry'
17 import type { IpcContext } from '../ipc/context'
18 import {
19 getMasterFontFamilies,
20 MASTER_CSS_RELATIVE_PATH,
21 MASTER_HTML_RELATIVE_PATH,
22 normalizeMasterConfig,
23 type SessionMasterConfig,
24 type SessionMasterStatus
25 } from '@shared/master'
26 import {
27 MASTER_LAYOUTS_RELATIVE_PATH,
28 normalizeSessionLayoutLibrary,
29 type SessionLayoutLibrary,
30 type SessionLayoutLibraryStatus
31 } from '@shared/layout-master'
32 import {
33 getSessionMasterHtmlPath,
34 getSessionMasterLayoutsPath,
35 getSessionMasterPath,
36 readSessionLayoutLibrary,
37 readSessionMaster,
38 writeSessionLayoutLibrary,
39 writeSessionMaster
40 } from './master-service'
41
42 type FileSnapshot = {
43 path: string
44 exists: boolean
45 content?: Buffer
46 }
47
48 type ExistingPage = {
49 record: SessionPageRecord
50 htmlPath: string
51 html: string
52 }
53
54 type PageCounts = Pick<
55 SessionMasterStatus,
56 'linkedPageCount' | 'unlinkedPageCount' | 'missingPageCount' | 'totalPageCount'
57 >
58
59 const sessionLocks = new Map<string, Promise<void>>()
60
61 const runExclusive = async <T>(sessionId: string, task: () => Promise<T>): Promise<T> => {
62 let release: () => void = () => undefined
63 const current = new Promise<void>((resolve) => {
64 release = resolve
65 })
66 const previous = sessionLocks.get(sessionId) || Promise.resolve()
67 sessionLocks.set(sessionId, current)
68 await previous
69 try {
70 return await task()
71 } finally {
72 release()
73 if (sessionLocks.get(sessionId) === current) sessionLocks.delete(sessionId)
74 }
75 }
76
77 const isInside = (candidate: string, root: string): boolean => {
78 const relative = path.relative(root, candidate)
79 return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
80 }
81
82 const readSnapshot = async (filePath: string): Promise<FileSnapshot> => {
83 try {
84 return { path: filePath, exists: true, content: await fs.promises.readFile(filePath) }
85 } catch (error) {
86 if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { path: filePath, exists: false }
87 throw error
88 }
89 }
90
91 const writeAtomically = async (filePath: string, content: string | Buffer): Promise<void> => {
92 const tempPath = `${filePath}.${crypto.randomUUID()}.tmp`
93 await fs.promises.mkdir(path.dirname(filePath), { recursive: true })
94 try {
95 await fs.promises.writeFile(tempPath, content)
96 await fs.promises.rename(tempPath, filePath)
97 } finally {
98 await fs.promises.rm(tempPath, { force: true }).catch(() => undefined)
99 }
100 }
101
102 const restoreSnapshot = async (snapshot: FileSnapshot): Promise<void> => {
103 if (!snapshot.exists) {
104 await fs.promises.rm(snapshot.path, { force: true })
105 return
106 }
107 await writeAtomically(snapshot.path, snapshot.content || Buffer.alloc(0))
108 }
109
110 const getRevision = (css: string): string =>
111 crypto.createHash('sha256').update(css, 'utf8').digest('hex')
112
113 const assertMutableSession = (ctx: IpcContext, sessionId: string): void => {
114 const runState = ctx.sessionRunStates.get(sessionId)
115 if (runState?.status === 'queued' || runState?.status === 'running') {
116 throw new Error('当前会话正在生成或编辑,暂时不能修改母版。')
117 }
118 }
119
120 const resolveExistingPages = async (
121 projectDir: string,
122 records: SessionPageRecord[]
123 ): Promise<{ pages: ExistingPage[]; missingPageCount: number }> => {
124 const projectRoot = await fs.promises.realpath(projectDir)
125 const pages: ExistingPage[] = []
126 let missingPageCount = 0
127 for (const record of records) {
128 const rawPath = typeof record.html_path === 'string' ? record.html_path.trim() : ''
129 const candidate = path.resolve(projectRoot, rawPath || `${record.file_slug}.html`)
130 try {
131 const htmlPath = await fs.promises.realpath(candidate)
132 if (!isInside(htmlPath, projectRoot)) {
133 missingPageCount += 1
134 continue
135 }
136 pages.push({ record, htmlPath, html: await fs.promises.readFile(htmlPath, 'utf-8') })
137 } catch {
138 missingPageCount += 1
139 }
140 }
141 return { pages, missingPageCount }
142 }
143
144 const summarizePages = async (
145 projectDir: string,
146 records: SessionPageRecord[],
147 masterExists: boolean
148 ): Promise<PageCounts> => {
149 const { pages, missingPageCount } = await resolveExistingPages(projectDir, records)
150 const linkedPageCount = masterExists
151 ? pages.filter((page) => hasUniqueMasterStyleLink(page.html)).length
152 : 0
153 return {
154 linkedPageCount,
155 unlinkedPageCount: pages.length - linkedPageCount,
156 missingPageCount,
157 totalPageCount: records.length
158 }
159 }
160
161 const getStatus = async (
162 ctx: IpcContext,
163 sessionId: string,
164 projectDir: string
165 ): Promise<SessionMasterStatus> => {
166 const [master, records] = await Promise.all([
167 readSessionMaster(projectDir),
168 ctx.db.listSessionPages(sessionId)
169 ])
170 return {
171 ...master,
172 revision: getRevision(`${master.css}\n${master.html}`),
173 ...(await summarizePages(projectDir, records, master.exists)),
174 disabledPageIds: (await resolveExistingPages(projectDir, records)).pages
175 .filter((page) => isMasterElementsDisabled(page.html))
176 .map((page) => page.record.id)
177 }
178 }
179
180 const getAllowedPaths = async (projectDir: string, pages: ExistingPage[]): Promise<string[]> => {
181 const root = await fs.promises.realpath(projectDir)
182 return [
183 MASTER_CSS_RELATIVE_PATH,
184 MASTER_HTML_RELATIVE_PATH,
185 ...pages.map((page) => path.relative(root, page.htmlPath).split(path.sep).join('/'))
186 ]
187 }
188
189 const getFontAllowedPaths = async (
190 projectDir: string,
191 resources: Awaited<ReturnType<typeof resolveProjectFontResources>>
192 ): Promise<string[]> => {
193 const root = path.resolve(projectDir)
194 return resources.assets
195 .map((asset) => path.relative(root, path.resolve(asset.targetPath)).split(path.sep).join('/'))
196 .filter((relativePath) => relativePath.startsWith('assets/fonts/'))
197 }
198
199 const getBackgroundImageAllowedPaths = async (
200 projectDir: string,
201 config: SessionMasterConfig
202 ): Promise<string[]> => {
203 if (config.backgroundStyle !== 'image' || !config.backgroundImage) return []
204 const projectRoot = await fs.promises.realpath(projectDir)
205 const imageRootPath = path.resolve(projectRoot, 'images')
206 const imagePath = path.resolve(projectRoot, config.backgroundImage)
207 const relativePath = path.relative(projectRoot, imagePath).split(path.sep).join('/')
208 if (!relativePath.startsWith('images/') || !isInside(imagePath, imageRootPath)) {
209 throw new Error('母版背景图片路径无效。')
210 }
211 const [imageRoot, imageStat] = await Promise.all([
212 fs.promises.realpath(imageRootPath).catch(() => ''),
213 fs.promises.lstat(imagePath).catch(() => null)
214 ])
215 if (
216 !imageRoot ||
217 !isInside(imageRoot, projectRoot) ||
218 !imageStat?.isFile() ||
219 imageStat.isSymbolicLink()
220 ) {
221 throw new Error('母版背景图片不存在或不安全。')
222 }
223 const resolvedImagePath = await fs.promises.realpath(imagePath).catch(() => '')
224 if (!resolvedImagePath || !isInside(resolvedImagePath, imageRoot)) {
225 throw new Error('母版背景图片不存在或不安全。')
226 }
227 return [relativePath]
228 }
229
230 const getMasterElementImageAllowedPaths = async (
231 projectDir: string,
232 config: SessionMasterConfig
233 ): Promise<string[]> => {
234 const elements = normalizeMasterConfig(config).elements
235 if (!elements?.logoImage) return []
236 const projectRoot = await fs.promises.realpath(projectDir)
237 const imageRootPath = path.resolve(projectRoot, 'images')
238 const imagePath = path.resolve(projectRoot, elements.logoImage)
239 const relativePath = path.relative(projectRoot, imagePath).split(path.sep).join('/')
240 if (!relativePath.startsWith('images/') || !isInside(imagePath, imageRootPath)) {
241 throw new Error('母版 Logo 图片路径无效。')
242 }
243 const [imageRoot, imageStat] = await Promise.all([
244 fs.promises.realpath(imageRootPath).catch(() => ''),
245 fs.promises.lstat(imagePath).catch(() => null)
246 ])
247 if (
248 !imageRoot ||
249 !isInside(imageRoot, projectRoot) ||
250 !imageStat?.isFile() ||
251 imageStat.isSymbolicLink()
252 ) {
253 throw new Error('母版 Logo 图片不存在或不安全。')
254 }
255 const resolvedImagePath = await fs.promises.realpath(imagePath).catch(() => '')
256 if (!resolvedImagePath || !isInside(resolvedImagePath, imageRoot)) {
257 throw new Error('母版 Logo 图片不存在或不安全。')
258 }
259 return [relativePath]
260 }
261
262 const rewriteUnlinkedPages = (pages: ExistingPage[]): Array<{ path: string; html: string }> =>
263 pages
264 .map((page) => ({
265 path: page.htmlPath,
266 html: setMasterPageNumber(ensureMasterStyleLink(page.html), page.record.page_number)
267 }))
268 .filter((page, index) => page.html !== pages[index]?.html)
269
270 const restoreAll = async (snapshots: FileSnapshot[]): Promise<void> => {
271 for (const snapshot of [...snapshots].reverse()) await restoreSnapshot(snapshot)
272 }
273
274 export async function getSessionMasterStatus(
275 ctx: IpcContext,
276 sessionId: string
277 ): Promise<SessionMasterStatus> {
278 const projectDir = await ctx.resolveSessionProjectDir(sessionId)
279 return getStatus(ctx, sessionId, projectDir)
280 }
281
282 export async function getSessionLayoutLibraryStatus(
283 ctx: IpcContext,
284 sessionId: string
285 ): Promise<SessionLayoutLibraryStatus> {
286 const projectDir = await ctx.resolveSessionProjectDir(sessionId)
287 const result = await readSessionLayoutLibrary(projectDir)
288 return {
289 ...result,
290 revision: getRevision(JSON.stringify(result.library))
291 }
292 }
293
294 export async function saveSessionLayoutLibrary(
295 ctx: IpcContext,
296 sessionId: string,
297 library: SessionLayoutLibrary
298 ): Promise<SessionLayoutLibraryStatus> {
299 return runExclusive(sessionId, async () => {
300 assertMutableSession(ctx, sessionId)
301 const projectDir = await ctx.resolveSessionProjectDir(sessionId)
302 const history = new GitHistoryService(ctx.db)
303 await history.ensureBaseline(sessionId, projectDir)
304 const snapshot = await readSnapshot(getSessionMasterLayoutsPath(projectDir))
305 try {
306 const result = await writeSessionLayoutLibrary(
307 projectDir,
308 normalizeSessionLayoutLibrary(library)
309 )
310 const operation = await history.recordOperation({
311 sessionId,
312 projectDir,
313 type: 'edit',
314 scope: 'session',
315 prompt: '更新演示版式母版',
316 metadata: { feature: 'layout-master', action: 'save-layout-mappings' },
317 allowedPaths: [MASTER_LAYOUTS_RELATIVE_PATH]
318 })
319 try {
320 return {
321 ...result,
322 revision: getRevision(JSON.stringify(result.library))
323 }
324 } catch (error) {
325 if (operation) {
326 await history.rollbackCommittedOperation({
327 sessionId,
328 projectDir,
329 operation,
330 allowedPaths: [MASTER_LAYOUTS_RELATIVE_PATH],
331 reason: error instanceof Error ? error.message : String(error)
332 })
333 }
334 throw error
335 }
336 } catch (error) {
337 await restoreSnapshot(snapshot).catch(() => undefined)
338 throw error
339 }
340 })
341 }
342
343 export async function saveSessionMaster(
344 ctx: IpcContext,
345 sessionId: string,
346 config: SessionMasterConfig
347 ): Promise<SessionMasterStatus> {
348 return runExclusive(sessionId, async () => {
349 assertMutableSession(ctx, sessionId)
350 const normalizedConfig = normalizeMasterConfig(config)
351 const projectDir = await ctx.resolveSessionProjectDir(sessionId)
352 const history = new GitHistoryService(ctx.db)
353 await history.ensureBaseline(sessionId, projectDir)
354 const records = await ctx.db.listSessionPages(sessionId)
355 const { pages, missingPageCount } = await resolveExistingPages(projectDir, records)
356 if (missingPageCount > 0) throw new Error('存在缺失或不安全的页面文件,无法保存并应用母版。')
357 const fontResources = await resolveProjectFontResources(
358 getMasterFontFamilies(normalizedConfig),
359 projectDir
360 )
361 const backgroundImageAllowedPaths = await getBackgroundImageAllowedPaths(
362 projectDir,
363 normalizedConfig
364 )
365 const masterElementImageAllowedPaths = await getMasterElementImageAllowedPaths(
366 projectDir,
367 normalizedConfig
368 )
369 const masterSnapshot = await readSnapshot(getSessionMasterPath(projectDir))
370 const masterHtmlSnapshot = await readSnapshot(getSessionMasterHtmlPath(projectDir))
371 const rewrittenPages = rewriteUnlinkedPages(pages)
372 const pageSnapshots = await Promise.all(rewrittenPages.map((page) => readSnapshot(page.path)))
373 const fontSnapshots = await Promise.all(
374 fontResources.assets.map((asset) => readSnapshot(asset.targetPath))
375 )
376 const allowedPaths = [
377 ...(await getAllowedPaths(projectDir, pages)),
378 ...(await getFontAllowedPaths(projectDir, fontResources)),
379 ...backgroundImageAllowedPaths,
380 ...masterElementImageAllowedPaths
381 ]
382 try {
383 await copyProjectFontResources(fontResources)
384 await writeSessionMaster(projectDir, normalizedConfig, fontResources.css)
385 for (const page of rewrittenPages) await writeAtomically(page.path, page.html)
386 const operation = await history.recordOperation({
387 sessionId,
388 projectDir,
389 type: 'edit',
390 scope: 'session',
391 prompt: '修改并应用演示母版',
392 metadata: { feature: 'slide-master', action: 'save-and-apply' },
393 allowedPaths
394 })
395 try {
396 return await getStatus(ctx, sessionId, projectDir)
397 } catch (error) {
398 if (operation) {
399 await history.rollbackCommittedOperation({
400 sessionId,
401 projectDir,
402 operation,
403 allowedPaths,
404 reason: error instanceof Error ? error.message : String(error)
405 })
406 }
407 throw error
408 }
409 } catch (error) {
410 await restoreAll([
411 masterSnapshot,
412 masterHtmlSnapshot,
413 ...pageSnapshots,
414 ...fontSnapshots
415 ]).catch(() => undefined)
416 throw error
417 }
418 })
419 }
420
421 export async function setSessionMasterPageOverride(
422 ctx: IpcContext,
423 sessionId: string,
424 pageId: string,
425 disabled: boolean
426 ): Promise<{ disabled: boolean }> {
427 return runExclusive(sessionId, async () => {
428 assertMutableSession(ctx, sessionId)
429 const projectDir = await ctx.resolveSessionProjectDir(sessionId)
430 const records = await ctx.db.listSessionPages(sessionId)
431 const { pages, missingPageCount } = await resolveExistingPages(projectDir, records)
432 if (missingPageCount > 0) throw new Error('存在缺失或不安全的页面文件,无法更新页面母版设置。')
433 const page = pages.find(
434 (item) => item.record.id === pageId || item.record.file_slug === pageId || item.record.legacy_page_id === pageId
435 )
436 if (!page) throw new Error('未找到要更新的页面。')
437 if (isMasterElementsDisabled(page.html) === disabled) return { disabled }
438
439 const history = new GitHistoryService(ctx.db)
440 await history.ensureBaseline(sessionId, projectDir)
441 const snapshot = await readSnapshot(page.htmlPath)
442 const html = setMasterElementsDisabled(page.html, disabled)
443 const root = await fs.promises.realpath(projectDir)
444 const allowedPaths = [path.relative(root, page.htmlPath).split(path.sep).join('/')]
445 try {
446 await writeAtomically(page.htmlPath, html)
447 await history.recordOperation({
448 sessionId,
449 projectDir,
450 type: 'edit',
451 scope: 'page',
452 prompt: disabled ? '隐藏本页母版全局元素' : '显示本页母版全局元素',
453 metadata: { feature: 'slide-master', action: 'set-page-elements-override', disabled },
454 allowedPaths
455 })
456 return { disabled }
457 } catch (error) {
458 await restoreSnapshot(snapshot).catch(() => undefined)
459 throw error
460 }
461 })
462 }
463
463 lines TYPESCRIPT