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