| 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 |