返回 oh-my-ppt
page-management-handlers.ts
根目录 / src / main / session / page-management-handlers.ts
1 import { ipcMain } from 'electron'
2 import type { IpcContext } from '../ipc/context'
3 import {
4 createBlankSessionPage,
5 duplicateSessionPage,
6 loadEditableSessionPages,
7 persistManagedPages,
8 renameSessionPageTitle
9 } from './page-management-service'
10 import { migrateLegacyPageOutlinesToSourceSkeletons } from './page-outline-utils'
11 import {
12 ensureHistoryBaselineSafe,
13 recordHistoryOperationStrict
14 } from '../history/git-history-service'
15
16 export function registerPageManagementHandlers(ctx: IpcContext): void {
17 ipcMain.handle('session:migratePageOutlinesToSourceSkeletons', async (_event, payload) => {
18 const record =
19 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
20 const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
21 if (!sessionId) throw new Error('sessionId 不能为空')
22 return migrateLegacyPageOutlinesToSourceSkeletons(ctx.db, sessionId)
23 })
24
25 ipcMain.handle('session:reorderPages', async (_event, payload) => {
26 const { sessionId, orderedPageIds, selectedPageId } = payload as {
27 sessionId: string
28 orderedPageIds: string[]
29 selectedPageId?: string
30 }
31 const { projectDir, indexPath, deckTitle, pages } = await loadEditableSessionPages(
32 ctx,
33 sessionId
34 )
35 if (orderedPageIds.length !== pages.length) {
36 throw new Error('orderedPageIds length mismatch')
37 }
38 const pageMap = new Map(pages.map((p) => [p.id, p]))
39 const uniqueOrderedIds = new Set(orderedPageIds)
40 if (uniqueOrderedIds.size !== orderedPageIds.length) {
41 throw new Error('orderedPageIds contains duplicate page ids')
42 }
43 for (const id of orderedPageIds) {
44 if (!pageMap.has(id)) throw new Error(`Unknown page id: ${id}`)
45 }
46 const beforeOrder = pages.map((p) => ({
47 id: p.id,
48 pageNumber: p.pageNumber,
49 pageId: p.pageId,
50 title: p.title
51 }))
52 const reordered = orderedPageIds.map((id) => {
53 return pageMap.get(id)!
54 })
55 const afterOrder = reordered.map((p, index) => ({
56 id: p.id,
57 pageNumber: index + 1,
58 pageId: p.pageId,
59 title: p.title
60 }))
61 const movedPages = afterOrder
62 .map((item, index) => {
63 const fromIndex = beforeOrder.findIndex((x) => x.id === item.id)
64 return {
65 id: item.id,
66 title: item.title,
67 from: fromIndex >= 0 ? fromIndex + 1 : null,
68 to: index + 1
69 }
70 })
71 .filter((item) => item.from !== item.to)
72 const shrinkTitle = (title: string): string => {
73 const clean = title.replace(/\s+/g, ' ').trim()
74 if (clean.length <= 16) return clean
75 return `${clean.slice(0, 16)}…`
76 }
77 const movedPreview = movedPages
78 .slice(0, 2)
79 .map((item) => `P${item.from}->P${item.to}《${shrinkTitle(item.title)}》`)
80 .join(';')
81 const operationPrompt =
82 movedPages.length > 0
83 ? `调整页面顺序:${movedPreview}${movedPages.length > 2 ? `;等 ${movedPages.length} 项` : ''}`
84 : '调整页面顺序(位置未变化)'
85 await ensureHistoryBaselineSafe(ctx.db, sessionId, projectDir)
86
87 const result = await persistManagedPages(ctx, {
88 sessionId,
89 projectDir,
90 indexPath,
91 deckTitle,
92 pages: reordered,
93 operation: 'reorder',
94 prompt: operationPrompt
95 })
96 await recordHistoryOperationStrict(ctx.db, {
97 sessionId,
98 type: 'reorder',
99 scope: 'session',
100 projectDir,
101 prompt: operationPrompt,
102 metadata: {
103 changedPageIds: result.map((p) => p.id),
104 selectedPageId: selectedPageId || null,
105 totalPages: result.length,
106 movedCount: movedPages.length,
107 movedPages,
108 beforeOrder,
109 afterOrder
110 }
111 })
112
113 return {
114 ok: true,
115 generatedPages: result.map((p) => ({
116 id: p.id,
117 pageNumber: p.pageNumber,
118 pageId: p.pageId,
119 title: p.title,
120 contentOutline: p.contentOutline?.trim() || null,
121 html: '',
122 htmlPath: p.htmlPath,
123 status: p.status,
124 error: p.error
125 })),
126 selectedPageId: selectedPageId || null
127 }
128 })
129
130 ipcMain.handle('session:deletePages', async (_event, payload) => {
131 const { sessionId, pageIds, selectedPageId } = payload as {
132 sessionId: string
133 pageIds: string[]
134 selectedPageId?: string
135 }
136 const { projectDir, indexPath, deckTitle, pages } = await loadEditableSessionPages(
137 ctx,
138 sessionId
139 )
140 if (!pageIds.length) throw new Error('pageIds is empty')
141 const pageMap = new Map(pages.map((p) => [p.id, p]))
142 const uniqueDeleteIds = new Set(pageIds)
143 if (uniqueDeleteIds.size !== pageIds.length) {
144 throw new Error('pageIds contains duplicate page ids')
145 }
146 for (const id of pageIds) {
147 if (!pageMap.has(id)) throw new Error(`Unknown page id: ${id}`)
148 }
149 if (pages.length - uniqueDeleteIds.size < 1) throw new Error('Cannot delete last page')
150 const deleteSet = new Set(pageIds)
151 const beforeOrder = pages.map((p) => ({
152 id: p.id,
153 pageNumber: p.pageNumber,
154 pageId: p.pageId,
155 title: p.title
156 }))
157 const firstDeletedIndex = pages.findIndex((p) => deleteSet.has(p.id))
158 const remaining = pages.filter((p) => !deleteSet.has(p.id))
159 const deletedPages = pages.filter((p) => deleteSet.has(p.id))
160 const afterOrder = remaining.map((p, index) => ({
161 id: p.id,
162 pageNumber: index + 1,
163 pageId: p.pageId,
164 title: p.title
165 }))
166 const shrinkTitle = (title: string): string => {
167 const clean = title.replace(/\s+/g, ' ').trim()
168 if (clean.length <= 16) return clean
169 return `${clean.slice(0, 16)}…`
170 }
171 const deletedPreview = deletedPages
172 .slice(0, 3)
173 .map((item) => `P${item.pageNumber}《${shrinkTitle(item.title)}》`)
174 .join(';')
175 const deletePrompt =
176 deletedPages.length > 0
177 ? `删除页面:${deletedPreview}${deletedPages.length > 3 ? `;等 ${deletedPages.length} 页` : ''}`
178 : `删除页面:${pageIds.length} 页`
179 await ensureHistoryBaselineSafe(ctx.db, sessionId, projectDir)
180
181 const result = await persistManagedPages(ctx, {
182 sessionId,
183 projectDir,
184 indexPath,
185 deckTitle,
186 pages: remaining,
187 operation: 'delete',
188 deletedPageIds: pageIds,
189 prompt: deletePrompt
190 })
191 await recordHistoryOperationStrict(ctx.db, {
192 sessionId,
193 type: 'delete',
194 scope: 'session',
195 projectDir,
196 prompt: deletePrompt,
197 metadata: {
198 deletedPageIds: pageIds,
199 selectedPageId: selectedPageId || null,
200 deletedCount: pageIds.length,
201 totalPagesAfterDelete: result.length,
202 beforeOrder,
203 afterOrder
204 }
205 })
206
207 let newSelectedId = selectedPageId || null
208 if (selectedPageId && deleteSet.has(selectedPageId)) {
209 const nextIndex = Math.min(Math.max(firstDeletedIndex, 0), result.length - 1)
210 newSelectedId = result.length > 0 ? result[nextIndex].id : null
211 }
212
213 return {
214 ok: true,
215 generatedPages: result.map((p) => ({
216 id: p.id,
217 pageNumber: p.pageNumber,
218 pageId: p.pageId,
219 title: p.title,
220 contentOutline: p.contentOutline?.trim() || null,
221 html: '',
222 htmlPath: p.htmlPath,
223 status: p.status,
224 error: p.error
225 })),
226 selectedPageId: newSelectedId
227 }
228 })
229
230 ipcMain.handle('session:createBlankPage', async (_event, payload) => {
231 const record =
232 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
233 const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
234 const sourcePageId = typeof record.sourcePageId === 'string' ? record.sourcePageId.trim() : ''
235 if (!sessionId) throw new Error('sessionId 不能为空')
236 if (!sourcePageId) throw new Error('sourcePageId 不能为空')
237 const { projectDir, pages } = await loadEditableSessionPages(ctx, sessionId)
238 await ensureHistoryBaselineSafe(ctx.db, sessionId, projectDir)
239 const sourcePage = pages.find(
240 (page) => page.id === sourcePageId || page.pageId === sourcePageId
241 )
242 const result = await createBlankSessionPage(ctx, {
243 sessionId,
244 sourcePageId
245 })
246 const prompt = sourcePage
247 ? `新增空白页:复制 P${sourcePage.pageNumber}《${sourcePage.title}》`
248 : '新增空白页'
249 await recordHistoryOperationStrict(ctx.db, {
250 sessionId,
251 type: 'addPage',
252 scope: 'session',
253 projectDir,
254 prompt,
255 metadata: {
256 addPage: true,
257 blankPage: true,
258 sourcePageId,
259 selectedPageId: result.selectedPageId,
260 totalPages: result.pages.length
261 }
262 })
263
264 return {
265 ok: true,
266 generatedPages: result.pages.map((p) => ({
267 id: p.id,
268 pageNumber: p.pageNumber,
269 pageId: p.pageId,
270 title: p.title,
271 contentOutline: p.contentOutline?.trim() || null,
272 html: p.html || '',
273 htmlPath: p.htmlPath,
274 status: p.status,
275 error: p.error
276 })),
277 selectedPageId: result.selectedPageId
278 }
279 })
280
281 ipcMain.handle('session:duplicatePage', async (_event, payload) => {
282 const record =
283 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
284 const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
285 const sourcePageId = typeof record.sourcePageId === 'string' ? record.sourcePageId.trim() : ''
286 if (!sessionId) throw new Error('sessionId 不能为空')
287 if (!sourcePageId) throw new Error('sourcePageId 不能为空')
288 const { projectDir, pages } = await loadEditableSessionPages(ctx, sessionId)
289 await ensureHistoryBaselineSafe(ctx.db, sessionId, projectDir)
290 const sourcePage = pages.find(
291 (page) => page.id === sourcePageId || page.pageId === sourcePageId
292 )
293 const result = await duplicateSessionPage(ctx, {
294 sessionId,
295 sourcePageId
296 })
297 const prompt = sourcePage
298 ? `复制页面:P${sourcePage.pageNumber}《${sourcePage.title}》`
299 : '复制页面'
300 await recordHistoryOperationStrict(ctx.db, {
301 sessionId,
302 type: 'addPage',
303 scope: 'session',
304 projectDir,
305 prompt,
306 metadata: {
307 addPage: true,
308 duplicatePage: true,
309 sourcePageId,
310 selectedPageId: result.selectedPageId,
311 totalPages: result.pages.length
312 }
313 })
314
315 return {
316 ok: true,
317 generatedPages: result.pages.map((p) => ({
318 id: p.id,
319 pageNumber: p.pageNumber,
320 pageId: p.pageId,
321 title: p.title,
322 contentOutline: p.contentOutline?.trim() || null,
323 html: p.html || '',
324 htmlPath: p.htmlPath,
325 status: p.status,
326 error: p.error
327 })),
328 selectedPageId: result.selectedPageId
329 }
330 })
331
332 ipcMain.handle('session:updatePageTitle', async (_event, payload) => {
333 const record =
334 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
335 const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
336 const pageId = typeof record.pageId === 'string' ? record.pageId.trim() : ''
337 const title = typeof record.title === 'string' ? record.title.replace(/\s+/g, ' ').trim() : ''
338 if (!sessionId) throw new Error('sessionId 不能为空')
339 if (!pageId) throw new Error('pageId 不能为空')
340 if (!title) throw new Error('页面标题不能为空')
341
342 const { projectDir, pages } = await loadEditableSessionPages(ctx, sessionId)
343 const page = pages.find((item) => item.id === pageId || item.pageId === pageId)
344 if (!page) throw new Error('未找到要修改标题的页面')
345 if (page.title === title) {
346 return {
347 ok: true,
348 generatedPages: pages.map((p) => ({
349 id: p.id,
350 pageNumber: p.pageNumber,
351 pageId: p.pageId,
352 title: p.title,
353 contentOutline: p.contentOutline?.trim() || null,
354 html: '',
355 htmlPath: p.htmlPath,
356 status: p.status,
357 error: p.error
358 })),
359 selectedPageId: page.id
360 }
361 }
362
363 await ensureHistoryBaselineSafe(ctx.db, sessionId, projectDir)
364 const result = await renameSessionPageTitle(ctx, {
365 sessionId,
366 pageId,
367 title
368 })
369 const prompt = `修改页面标题:P${page.pageNumber}《${page.title}》->《${title}》`
370 await recordHistoryOperationStrict(ctx.db, {
371 sessionId,
372 type: 'edit',
373 scope: 'page',
374 projectDir,
375 prompt,
376 metadata: {
377 pageId: page.id,
378 pageSlug: page.pageId,
379 oldTitle: page.title,
380 newTitle: title,
381 selectedPageId: result.selectedPageId,
382 titleEdit: true
383 }
384 })
385
386 return {
387 ok: true,
388 generatedPages: result.pages.map((p) => ({
389 id: p.id,
390 pageNumber: p.pageNumber,
391 pageId: p.pageId,
392 title: p.title,
393 contentOutline: p.contentOutline?.trim() || null,
394 html: p.html || '',
395 htmlPath: p.htmlPath,
396 status: p.status,
397 error: p.error
398 })),
399 selectedPageId: result.selectedPageId
400 }
401 })
402
403 ipcMain.handle('session:updatePageOutline', async (_event, payload) => {
404 const record =
405 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
406 const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
407 const pageId = typeof record.pageId === 'string' ? record.pageId.trim() : ''
408 const contentOutline =
409 typeof record.contentOutline === 'string'
410 ? record.contentOutline.replace(/\s+/g, ' ').trim()
411 : ''
412 if (!sessionId) throw new Error('sessionId 不能为空')
413 if (!pageId) throw new Error('pageId 不能为空')
414
415 const { projectDir, pages } = await loadEditableSessionPages(ctx, sessionId)
416 const page = pages.find((item) => item.id === pageId || item.pageId === pageId)
417 if (!page) throw new Error('未找到要修改大纲的页面')
418 const oldOutline = page.contentOutline?.trim() || ''
419 if (oldOutline === contentOutline) {
420 return {
421 ok: true,
422 generatedPages: pages.map((p) => ({
423 id: p.id,
424 pageNumber: p.pageNumber,
425 pageId: p.pageId,
426 title: p.title,
427 contentOutline: p.contentOutline?.trim() || null,
428 html: '',
429 htmlPath: p.htmlPath,
430 status: p.status,
431 error: p.error
432 })),
433 selectedPageId: page.id
434 }
435 }
436
437 await ensureHistoryBaselineSafe(ctx.db, sessionId, projectDir)
438 const [session, skeletons] = await Promise.all([
439 ctx.db.getSession(sessionId),
440 ctx.db.listSourcePageSkeletons(sessionId)
441 ])
442 const skeleton = skeletons.find((item) => item.page_number === page.pageNumber)
443 const sourceDocumentPath =
444 skeleton?.source_document_path ||
445 session?.referenceDocumentPath ||
446 session?.reference_document_path ||
447 `legacy-outline:${sessionId}`
448 if (contentOutline) {
449 await ctx.db.upsertSourcePageSkeleton({
450 sessionId,
451 pageNumber: page.pageNumber,
452 title: page.title,
453 role: skeleton?.role || 'content',
454 sourceDocumentPath,
455 sourceDocumentName: skeleton?.source_document_name || session?.title || 'Manual outline',
456 sourceHeading: contentOutline,
457 headingLevel: skeleton?.heading_level || 1,
458 lineStart: skeleton?.line_start || page.pageNumber,
459 lineEnd: skeleton?.line_end || page.pageNumber,
460 reason: null,
461 confidence: skeleton?.confidence || 'medium'
462 })
463 } else {
464 await ctx.db.deleteSourcePageSkeleton(sessionId, page.pageNumber)
465 }
466
467 const refreshed = await loadEditableSessionPages(ctx, sessionId)
468 const prompt = `修改页面大纲:P${page.pageNumber}《${page.title}》`
469 await recordHistoryOperationStrict(ctx.db, {
470 sessionId,
471 type: 'edit',
472 scope: 'page',
473 projectDir,
474 prompt,
475 metadata: {
476 pageId: page.id,
477 pageSlug: page.pageId,
478 oldOutline,
479 newOutline: contentOutline,
480 selectedPageId: page.id,
481 outlineEdit: true
482 }
483 })
484
485 return {
486 ok: true,
487 generatedPages: refreshed.pages.map((p) => ({
488 id: p.id,
489 pageNumber: p.pageNumber,
490 pageId: p.pageId,
491 title: p.title,
492 contentOutline: p.contentOutline?.trim() || null,
493 html: p.html || '',
494 htmlPath: p.htmlPath,
495 status: p.status,
496 error: p.error
497 })),
498 selectedPageId: page.id
499 }
500 })
501 }
502
502 lines TYPESCRIPT