返回 oh-my-ppt
page-management-service.ts
根目录 / src / main / session / page-management-service.ts
1 import type { IpcContext } from '../ipc/context'
2 import * as fs from 'fs'
3 import path from 'path'
4 import * as cheerio from 'cheerio'
5 import { customAlphabet, nanoid } from 'nanoid'
6 import { buildProjectIndexHtml } from './template-builder'
7 import { ensureSessionRuntimeCompatible } from './runtime-assets'
8 import { carryIndexTransitionConfig } from './index-transition'
9 import { validatePersistedPageHtml } from '../presentation/html/html-utils'
10 import {
11 buildBlankPageHtmlFromSource,
12 buildDuplicatePageHtmlFromSource
13 } from './page-html-builders'
14 import { setMasterPageNumber } from '../presentation/html/master-link'
15 import type { SessionPageStatus } from '../db/schema'
16 import { resolveOutlinesForPages } from './page-outline-utils'
17 import { requireSessionSlideSize } from '@shared/slide-size'
18
19 const pageSlugId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 10)
20
21 const resolvePageHtmlPath = (
22 projectDir: string,
23 fileSlug: string,
24 candidatePath?: string | null
25 ): string => {
26 const projectRoot = path.resolve(projectDir)
27 const fallbackPath = path.resolve(projectRoot, `${fileSlug}.html`)
28 const rawCandidate = typeof candidatePath === 'string' ? candidatePath.trim() : ''
29 if (!rawCandidate) return fallbackPath
30 const resolvedCandidate = path.isAbsolute(rawCandidate)
31 ? path.resolve(rawCandidate)
32 : path.resolve(projectRoot, rawCandidate)
33 const relative = path.relative(projectRoot, resolvedCandidate)
34 if (relative.startsWith('..') || path.isAbsolute(relative)) return fallbackPath
35 return fs.existsSync(resolvedCandidate) ? resolvedCandidate : fallbackPath
36 }
37
38 export interface ManagedPage {
39 id: string
40 pageNumber: number
41 pageId: string
42 legacyPageId?: string
43 title: string
44 contentOutline?: string | null
45 layoutIntent?: string | null
46 layoutId?: string | null
47 layoutContractVersion?: number | null
48 htmlPath: string
49 html?: string
50 status?: SessionPageStatus
51 error?: string | null
52 }
53
54 export async function loadEditableSessionPages(
55 ctx: IpcContext,
56 sessionId: string
57 ): Promise<{
58 session: Record<string, unknown>
59 projectDir: string
60 indexPath: string
61 deckTitle: string
62 pages: ManagedPage[]
63 }> {
64 const session = await ctx.db.getSession(sessionId)
65 if (!session) throw new Error('Session not found')
66
67 const projectDir = await ctx.resolveSessionProjectDir(sessionId)
68 const indexPath = path.join(projectDir, 'index.html')
69 const deckTitle = (session as unknown as { title?: string }).title || 'Untitled'
70
71 const sessionPages = await ctx.db.listSessionPages(sessionId)
72 const outlineBySessionPageId = await resolveOutlinesForPages(ctx.db, sessionId, sessionPages)
73 const pages: ManagedPage[] = sessionPages.map((sp) => ({
74 id: sp.id,
75 pageNumber: sp.page_number,
76 pageId: sp.file_slug,
77 legacyPageId: sp.legacy_page_id || undefined,
78 title: sp.title,
79 contentOutline: outlineBySessionPageId.get(sp.id) || null,
80 layoutIntent: sp.layout_intent,
81 layoutId: sp.layout_id,
82 layoutContractVersion: sp.layout_contract_version,
83 htmlPath: resolvePageHtmlPath(projectDir, sp.file_slug, sp.html_path),
84 status: sp.status,
85 error: sp.error
86 }))
87
88 return { session: session as unknown as Record<string, unknown>, projectDir, indexPath, deckTitle, pages }
89 }
90
91 export async function persistManagedPages(
92 ctx: IpcContext,
93 args: {
94 sessionId: string
95 projectDir: string
96 indexPath: string
97 deckTitle: string
98 pages: ManagedPage[]
99 operation: 'reorder' | 'delete' | 'addPage' | 'rename'
100 deletedPageIds?: string[]
101 prompt: string
102 }
103 ): Promise<ManagedPage[]> {
104 const { db } = ctx
105 // Refresh assets only when runtime marker is missing/mismatched (mainly old sessions).
106 await ensureSessionRuntimeCompatible(ctx, args.projectDir)
107 // Keep caller order (drag result / filtered order), only rewrite contiguous page numbers.
108 const renumbered = args.pages.map((p, i) => ({ ...p, pageNumber: i + 1 }))
109 const pageUpdates = await Promise.all(
110 renumbered.map(async (page) => {
111 const source = await fs.promises.readFile(page.htmlPath, 'utf-8')
112 return { path: page.htmlPath, source, updated: setMasterPageNumber(source, page.pageNumber) }
113 })
114 )
115 const changedPageUpdates = pageUpdates.filter((page) => page.updated !== page.source)
116 const restorePageSnapshots = async (): Promise<void> => {
117 await Promise.all(
118 changedPageUpdates.map((page) => fs.promises.writeFile(page.path, page.source, 'utf-8'))
119 )
120 }
121 const currentSession = await db.getSession(args.sessionId)
122 const deckPages = renumbered.map((p) => ({
123 id: p.id,
124 pageNumber: p.pageNumber,
125 pageId: p.pageId,
126 title: p.title,
127 htmlPath: path.basename(p.htmlPath)
128 }))
129 const rebuiltIndexHtml = buildProjectIndexHtml(
130 args.deckTitle,
131 deckPages,
132 requireSessionSlideSize(currentSession)
133 )
134 const indexHtml = fs.existsSync(args.indexPath)
135 ? carryIndexTransitionConfig(
136 await fs.promises.readFile(args.indexPath, 'utf-8'),
137 rebuiltIndexHtml
138 )
139 : rebuiltIndexHtml
140 let currentMetadata: Record<string, unknown> = {}
141 try {
142 currentMetadata = JSON.parse((currentSession?.metadata as string | null) || '{}')
143 } catch {
144 currentMetadata = {}
145 }
146 const {
147 generatedPages: _generatedPages,
148 failedPages: _failedPages,
149 ...safeMetadata
150 } = currentMetadata as Record<string, unknown> & {
151 generatedPages?: unknown
152 failedPages?: unknown
153 }
154
155 try {
156 await Promise.all(
157 changedPageUpdates.map((page) => fs.promises.writeFile(page.path, page.updated, 'utf-8'))
158 )
159 await fs.promises.writeFile(`${args.indexPath}.tmp`, indexHtml, 'utf-8')
160 await db.persistSessionPageState({
161 sessionId: args.sessionId,
162 pages: renumbered.map((page) => ({ id: page.id, pageNumber: page.pageNumber })),
163 deletedPageIds: args.deletedPageIds,
164 metadata: {
165 ...safeMetadata,
166 entryMode: 'multi_page',
167 indexPath: args.indexPath
168 }
169 })
170 } catch (error) {
171 await restorePageSnapshots().catch(() => undefined)
172 await fs.promises.rm(`${args.indexPath}.tmp`, { force: true })
173 throw error
174 }
175 await fs.promises.rename(`${args.indexPath}.tmp`, args.indexPath)
176
177 return renumbered
178 }
179
180 export async function createBlankSessionPage(
181 ctx: IpcContext,
182 args: {
183 sessionId: string
184 sourcePageId: string
185 }
186 ): Promise<{ pages: ManagedPage[]; selectedPageId: string }> {
187 const { sessionId, sourcePageId } = args
188 const { projectDir, indexPath, deckTitle, pages } = await loadEditableSessionPages(ctx, sessionId)
189 if (pages.length === 0) throw new Error('当前会话没有可复制的页面')
190 const sourceIndex = pages.findIndex(
191 (page) => page.id === sourcePageId || page.pageId === sourcePageId
192 )
193 if (sourceIndex < 0) throw new Error('未找到要复制的页面')
194 const sourcePage = pages[sourceIndex]
195 if (!fs.existsSync(sourcePage.htmlPath)) throw new Error('源页面文件不存在')
196
197 await ensureSessionRuntimeCompatible(ctx, projectDir)
198 const insertAfterPageNumber = sourcePage.pageNumber
199 const nextPageEntityId = nanoid()
200 const nextPageId = `page-${pageSlugId()}`
201 const nextHtmlPath = path.join(projectDir, `${nextPageId}.html`)
202 const nextTitle = '新增空白页'
203 const sourceHtml = await fs.promises.readFile(sourcePage.htmlPath, 'utf-8')
204 const nextHtml = buildBlankPageHtmlFromSource({
205 html: sourceHtml,
206 oldPageId: sourcePage.pageId,
207 nextPageId,
208 pageNumber: insertAfterPageNumber + 1,
209 title: nextTitle
210 })
211 const validation = validatePersistedPageHtml(nextHtml, nextPageId)
212 if (!validation.valid) {
213 throw new Error(`空白页创建失败: ${validation.errors.join('; ')}`)
214 }
215 await fs.promises.writeFile(nextHtmlPath, nextHtml, 'utf-8')
216
217 const newPage: ManagedPage = {
218 id: nextPageEntityId,
219 pageNumber: insertAfterPageNumber + 1,
220 pageId: nextPageId,
221 title: nextTitle,
222 contentOutline: null,
223 layoutIntent: sourcePage.layoutIntent || null,
224 layoutId: sourcePage.layoutId || null,
225 layoutContractVersion: sourcePage.layoutContractVersion || null,
226 htmlPath: nextHtmlPath,
227 html: nextHtml,
228 status: 'completed',
229 error: null
230 }
231 const mergedPages = [
232 ...pages.slice(0, sourceIndex + 1),
233 newPage,
234 ...pages.slice(sourceIndex + 1)
235 ]
236
237 await ctx.db.upsertSessionPage({
238 id: newPage.id,
239 sessionId,
240 legacyPageId: null,
241 fileSlug: newPage.pageId,
242 pageNumber: newPage.pageNumber,
243 title: newPage.title,
244 htmlPath: newPage.htmlPath,
245 layoutIntent: newPage.layoutIntent || null,
246 layoutId: newPage.layoutId || null,
247 layoutContractVersion: newPage.layoutContractVersion || null,
248 status: 'completed',
249 error: null
250 })
251
252 const result = await persistManagedPages(ctx, {
253 sessionId,
254 projectDir,
255 indexPath,
256 deckTitle,
257 pages: mergedPages,
258 operation: 'addPage',
259 prompt: `新增空白页:复制 P${sourcePage.pageNumber}`
260 })
261 const project = await ctx.db.getProject(sessionId)
262 if (project?.id) await ctx.db.updateProjectStatus(project.id, 'draft')
263 await ctx.db.updateSessionStatus(sessionId, 'completed')
264 return { pages: result, selectedPageId: nextPageEntityId }
265 }
266
267 export async function duplicateSessionPage(
268 ctx: IpcContext,
269 args: {
270 sessionId: string
271 sourcePageId: string
272 }
273 ): Promise<{ pages: ManagedPage[]; selectedPageId: string }> {
274 const { sessionId, sourcePageId } = args
275 const { projectDir, indexPath, deckTitle, pages } = await loadEditableSessionPages(ctx, sessionId)
276 if (pages.length === 0) throw new Error('当前会话没有可复制的页面')
277 const sourceIndex = pages.findIndex(
278 (page) => page.id === sourcePageId || page.pageId === sourcePageId
279 )
280 if (sourceIndex < 0) throw new Error('未找到要复制的页面')
281 const sourcePage = pages[sourceIndex]
282 if (!fs.existsSync(sourcePage.htmlPath)) throw new Error('源页面文件不存在')
283
284 await ensureSessionRuntimeCompatible(ctx, projectDir)
285 const nextPageEntityId = nanoid()
286 const nextPageId = `page-${pageSlugId()}`
287 const nextHtmlPath = path.join(projectDir, `${nextPageId}.html`)
288 const nextTitle = `[副本]${sourcePage.title ?? ''}`
289 const sourceHtml = await fs.promises.readFile(sourcePage.htmlPath, 'utf-8')
290 const nextHtml = buildDuplicatePageHtmlFromSource({
291 html: sourceHtml,
292 oldPageId: sourcePage.pageId,
293 nextPageId,
294 pageNumber: sourcePage.pageNumber + 1,
295 title: nextTitle
296 })
297 const validation = validatePersistedPageHtml(nextHtml, nextPageId)
298 if (!validation.valid) {
299 throw new Error(`复制页面失败: ${validation.errors.join('; ')}`)
300 }
301 await fs.promises.writeFile(nextHtmlPath, nextHtml, 'utf-8')
302
303 const newPage: ManagedPage = {
304 id: nextPageEntityId,
305 // 占位页码,persistManagedPages 会按位置连续重排。
306 pageNumber: sourcePage.pageNumber + 1,
307 pageId: nextPageId,
308 title: nextTitle,
309 contentOutline: sourcePage.contentOutline || null,
310 layoutIntent: sourcePage.layoutIntent || null,
311 layoutId: sourcePage.layoutId || null,
312 layoutContractVersion: sourcePage.layoutContractVersion || null,
313 htmlPath: nextHtmlPath,
314 html: nextHtml,
315 status: sourcePage.status || 'completed',
316 error: null
317 }
318 // 插到源页紧后方(区别于空白页追加到末尾)。
319 const mergedPages = [...pages.slice(0, sourceIndex + 1), newPage, ...pages.slice(sourceIndex + 1)]
320
321 await ctx.db.upsertSessionPage({
322 id: newPage.id,
323 sessionId,
324 legacyPageId: null,
325 fileSlug: newPage.pageId,
326 pageNumber: newPage.pageNumber,
327 title: newPage.title,
328 htmlPath: newPage.htmlPath,
329 layoutIntent: newPage.layoutIntent || null,
330 layoutId: newPage.layoutId || null,
331 layoutContractVersion: newPage.layoutContractVersion || null,
332 status: newPage.status || 'completed',
333 error: null
334 })
335
336 const result = await persistManagedPages(ctx, {
337 sessionId,
338 projectDir,
339 indexPath,
340 deckTitle,
341 pages: mergedPages,
342 operation: 'addPage',
343 prompt: `复制页面:P${sourcePage.pageNumber}《${sourcePage.title}》`
344 })
345 const project = await ctx.db.getProject(sessionId)
346 if (project?.id) await ctx.db.updateProjectStatus(project.id, 'draft')
347 await ctx.db.updateSessionStatus(sessionId, 'completed')
348 return { pages: result, selectedPageId: nextPageEntityId }
349 }
350
351 export async function renameSessionPageTitle(
352 ctx: IpcContext,
353 args: {
354 sessionId: string
355 pageId: string
356 title: string
357 }
358 ): Promise<{ pages: ManagedPage[]; selectedPageId: string }> {
359 const title = args.title.replace(/\s+/g, ' ').trim()
360 if (!title) throw new Error('页面标题不能为空')
361 const { projectDir, indexPath, deckTitle, pages } = await loadEditableSessionPages(ctx, args.sessionId)
362 const page = pages.find((item) => item.id === args.pageId || item.pageId === args.pageId)
363 if (!page) throw new Error('未找到要修改标题的页面')
364
365 const nextPages = pages.map((item) =>
366 item.id === page.id
367 ? {
368 ...item,
369 title
370 }
371 : item
372 )
373
374 if (fs.existsSync(page.htmlPath)) {
375 const html = await fs.promises.readFile(page.htmlPath, 'utf-8')
376 const $ = cheerio.load(html, { scriptingEnabled: false })
377 $('title').text(title)
378 await fs.promises.writeFile(page.htmlPath, $.html(), 'utf-8')
379 }
380 await ctx.db.upsertSessionPage({
381 id: page.id,
382 sessionId: args.sessionId,
383 legacyPageId: page.legacyPageId || null,
384 fileSlug: page.pageId,
385 pageNumber: page.pageNumber,
386 title,
387 htmlPath: page.htmlPath,
388 layoutIntent: page.layoutIntent || null,
389 layoutId: page.layoutId || null,
390 layoutContractVersion: page.layoutContractVersion || null,
391 status: page.status || 'completed',
392 error: page.error || null
393 })
394
395 const result = await persistManagedPages(ctx, {
396 sessionId: args.sessionId,
397 projectDir,
398 indexPath,
399 deckTitle,
400 pages: nextPages,
401 operation: 'rename',
402 prompt: `修改页面标题:P${page.pageNumber}《${page.title}》->《${title}》`
403 })
404 const project = await ctx.db.getProject(args.sessionId)
405 if (project?.id) await ctx.db.updateProjectStatus(project.id, 'draft')
406 return { pages: result, selectedPageId: page.id }
407 }
408
408 lines TYPESCRIPT