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