返回 oh-my-ppt
save-as-new.ts
根目录 / src / main / session / save-as-new.ts
1 import { ipcMain } from 'electron'
2 import log from 'electron-log/main.js'
3 import crypto from 'crypto'
4 import fs from 'fs'
5 import path from 'path'
6 import { requireSessionSlideSize } from '@shared/slide-size'
7 import type { IpcContext } from '../ipc/context'
8 import { readAppLocale, uiText } from '../config/locale-utils'
9 import { ensureHistoryBaselineSafe } from '../history/git-history-service'
10 import { createSessionMasterIfMissing } from './master-service'
11
12 const copyDirectoryForNewSession = async (sourceDir: string, targetDir: string): Promise<void> => {
13 await fs.promises.mkdir(targetDir, { recursive: true })
14 const entries = await fs.promises.readdir(sourceDir, { withFileTypes: true })
15 for (const entry of entries) {
16 if (entry.name === '.git' || entry.name === 'history') continue
17 const sourcePath = path.join(sourceDir, entry.name)
18 const targetPath = path.join(targetDir, entry.name)
19 if (entry.isDirectory()) {
20 await copyDirectoryForNewSession(sourcePath, targetPath)
21 } else if (entry.isFile()) {
22 await fs.promises.copyFile(sourcePath, targetPath)
23 }
24 }
25 }
26
27 const SESSION_TEXT_FILE_EXTENSIONS = new Set([
28 '.css',
29 '.html',
30 '.js',
31 '.json',
32 '.md',
33 '.mjs',
34 '.svg',
35 '.txt',
36 '.xml'
37 ])
38
39 const replaceSessionIdInClonedTextFiles = async (
40 projectDir: string,
41 sourceSessionId: string,
42 newSessionId: string
43 ): Promise<void> => {
44 const entries = await fs.promises.readdir(projectDir, { withFileTypes: true })
45 for (const entry of entries) {
46 if (entry.name === '.git' || entry.name === 'history') continue
47 const entryPath = path.join(projectDir, entry.name)
48 if (entry.isDirectory()) {
49 await replaceSessionIdInClonedTextFiles(entryPath, sourceSessionId, newSessionId)
50 continue
51 }
52 if (!entry.isFile() || !SESSION_TEXT_FILE_EXTENSIONS.has(path.extname(entry.name))) continue
53
54 const content = await fs.promises.readFile(entryPath, 'utf-8')
55 if (!content.includes(sourceSessionId)) continue
56 await fs.promises.writeFile(entryPath, content.replaceAll(sourceSessionId, newSessionId), 'utf-8')
57 }
58 }
59
60 const parseJsonObject = (value: unknown): Record<string, unknown> => {
61 if (typeof value !== 'string' || value.trim().length === 0) return {}
62 try {
63 const parsed = JSON.parse(value) as unknown
64 return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
65 ? (parsed as Record<string, unknown>)
66 : {}
67 } catch {
68 return {}
69 }
70 }
71
72 const parseOptionalJson = (value: unknown): unknown | null => {
73 if (typeof value !== 'string' || value.trim().length === 0) return null
74 try {
75 return JSON.parse(value) as unknown
76 } catch {
77 return null
78 }
79 }
80
81 const remapClonedMetadataValue = (
82 value: unknown,
83 sourceProjectDir: string,
84 targetProjectDir: string,
85 sourceSessionId: string,
86 newSessionId: string
87 ): unknown => {
88 if (typeof value === 'string') {
89 return value
90 .replaceAll(sourceSessionId, newSessionId)
91 .replaceAll(sourceProjectDir, targetProjectDir)
92 }
93 if (Array.isArray(value)) {
94 return value.map((item) =>
95 remapClonedMetadataValue(
96 item,
97 sourceProjectDir,
98 targetProjectDir,
99 sourceSessionId,
100 newSessionId
101 )
102 )
103 }
104 if (value && typeof value === 'object') {
105 return Object.fromEntries(
106 Object.entries(value as Record<string, unknown>).map(([key, item]) => [
107 key,
108 remapClonedMetadataValue(
109 item,
110 sourceProjectDir,
111 targetProjectDir,
112 sourceSessionId,
113 newSessionId
114 )
115 ])
116 )
117 }
118 return value
119 }
120
121 const resolveClonedProjectPath = (
122 sourceProjectDir: string,
123 targetProjectDir: string,
124 candidatePath: string | null | undefined,
125 fallbackRelativePath: string
126 ): string => {
127 const sourceRoot = path.resolve(sourceProjectDir)
128 const targetRoot = path.resolve(targetProjectDir)
129 const fallback = fallbackRelativePath.replace(/^[/\\]+/, '')
130 const rawCandidate = typeof candidatePath === 'string' ? candidatePath.trim() : ''
131 let relativePath = rawCandidate
132
133 if (rawCandidate && path.isAbsolute(rawCandidate)) {
134 relativePath = path.relative(sourceRoot, path.resolve(rawCandidate))
135 }
136 if (!relativePath) relativePath = fallback
137
138 const normalizedRelative = relativePath.split(path.sep).join('/').replace(/^\/+/, '')
139 if (
140 !normalizedRelative ||
141 normalizedRelative.startsWith('../') ||
142 normalizedRelative === '..' ||
143 path.isAbsolute(normalizedRelative)
144 ) {
145 return path.join(targetRoot, fallback)
146 }
147 return path.join(targetRoot, normalizedRelative)
148 }
149
150 const resolveClonedSessionDocumentPath = (
151 sourceProjectDir: string,
152 targetProjectDir: string,
153 candidatePath: string | null | undefined,
154 sourceSessionId?: string,
155 newSessionId?: string
156 ): string | null => {
157 const rawCandidate = typeof candidatePath === 'string' ? candidatePath.trim() : ''
158 if (!rawCandidate) return null
159 if (
160 sourceSessionId &&
161 newSessionId &&
162 rawCandidate === `legacy-outline:${sourceSessionId}`
163 ) {
164 return `legacy-outline:${newSessionId}`
165 }
166 if (rawCandidate.startsWith('/docs/')) return rawCandidate
167
168 const sourceRoot = path.resolve(sourceProjectDir)
169 let relativePath = rawCandidate
170 if (path.isAbsolute(rawCandidate)) {
171 relativePath = path.relative(sourceRoot, path.resolve(rawCandidate))
172 }
173 const normalizedRelative = relativePath.split(path.sep).join('/').replace(/^\/+/, '')
174 if (
175 !normalizedRelative.startsWith('docs/') ||
176 normalizedRelative.startsWith('../') ||
177 normalizedRelative === '..' ||
178 path.isAbsolute(normalizedRelative)
179 ) {
180 return null
181 }
182
183 const targetPath = path.join(targetProjectDir, normalizedRelative)
184 return fs.existsSync(targetPath) ? `/${normalizedRelative}` : null
185 }
186
187 export function registerSessionSaveAsNewHandler(ctx: IpcContext): void {
188 const { db, resolveStoragePath, resolveSessionProjectDir, ensureSessionAssets, sessionRunStates } =
189 ctx
190
191 ipcMain.handle('session:saveAsNew', async (_event, payload: unknown) => {
192 const locale = await readAppLocale(ctx)
193 const record =
194 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
195 const sourceSessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
196 if (!sourceSessionId) {
197 throw new Error(uiText(locale, '会话 ID 不能为空', 'Session ID is required.'))
198 }
199
200 const sourceSession = await db.getSession(sourceSessionId)
201 if (!sourceSession) {
202 throw new Error(
203 uiText(locale, '会话不存在或已被删除', 'The session does not exist or has been deleted.')
204 )
205 }
206
207 const sourcePages = await db.listSessionPages(sourceSessionId)
208 if (sourcePages.length === 0) {
209 throw new Error(
210 uiText(locale, '当前会话没有可另存的页面', 'This session has no pages to save as new.')
211 )
212 }
213
214 const sourceProjectDir = await resolveSessionProjectDir(sourceSessionId)
215 if (!fs.existsSync(sourceProjectDir)) {
216 throw new Error(
217 uiText(locale, '当前会话目录不存在,无法另存', 'The current session directory is missing.')
218 )
219 }
220
221 const storagePath = await resolveStoragePath()
222 const newSessionId = crypto.randomUUID()
223 const targetProjectDir = path.join(storagePath, newSessionId)
224 const baseTitle =
225 typeof sourceSession.title === 'string' && sourceSession.title.trim()
226 ? sourceSession.title.trim()
227 : uiText(locale, '未命名会话', 'Untitled session')
228 const requestedTitle = typeof record.title === 'string' ? record.title.trim() : ''
229 if (Object.prototype.hasOwnProperty.call(record, 'title') && !requestedTitle) {
230 throw new Error(uiText(locale, '会话名称不能为空', 'Session title is required.'))
231 }
232 if (requestedTitle.length > 120) {
233 throw new Error(
234 uiText(locale, '会话名称不能超过 120 个字符', 'Session title cannot exceed 120 characters.')
235 )
236 }
237 const newTitle = requestedTitle || `${baseTitle}${uiText(locale, ' 副本', ' Copy')}`
238 const sourceProvider =
239 typeof sourceSession.provider === 'string' && sourceSession.provider.trim()
240 ? sourceSession.provider
241 : 'import'
242 const sourceModel =
243 typeof sourceSession.model === 'string' && sourceSession.model.trim()
244 ? sourceSession.model
245 : 'session-save-as-new'
246 const sourceImageModelConfigId =
247 typeof (sourceSession.imageModelConfigId ?? sourceSession.image_model_config_id) === 'string'
248 ? String(sourceSession.imageModelConfigId ?? sourceSession.image_model_config_id).trim()
249 : ''
250 const sourceVisualEnabled =
251 (sourceSession.visualEnabled === 1 || sourceSession.visual_enabled === 1) &&
252 Boolean(sourceImageModelConfigId)
253
254 try {
255 await copyDirectoryForNewSession(sourceProjectDir, targetProjectDir)
256 await replaceSessionIdInClonedTextFiles(targetProjectDir, sourceSessionId, newSessionId)
257 await ensureSessionAssets(targetProjectDir)
258 await createSessionMasterIfMissing(targetProjectDir)
259 const referenceDocumentPath = resolveClonedSessionDocumentPath(
260 sourceProjectDir,
261 targetProjectDir,
262 sourceSession.referenceDocumentPath ?? sourceSession.reference_document_path,
263 sourceSessionId,
264 newSessionId
265 )
266 const slideSize = requireSessionSlideSize(sourceSession)
267
268 await db.createSession({
269 id: newSessionId,
270 title: newTitle,
271 topic: sourceSession.topic || baseTitle,
272 styleId: sourceSession.styleId ?? undefined,
273 pageCount: sourcePages.length,
274 slideSizeId: slideSize.id,
275 slideWidth: slideSize.width,
276 slideHeight: slideSize.height,
277 referenceDocumentPath,
278 visualEnabled: sourceVisualEnabled,
279 imageModelConfigId: sourceVisualEnabled ? sourceImageModelConfigId : null,
280 provider: sourceProvider,
281 model: sourceModel
282 })
283 await db.copySessionStyleSnapshot(sourceSessionId, newSessionId)
284
285 const designContract = parseOptionalJson(sourceSession.designContract)
286 if (designContract) {
287 await db.updateSessionDesignContract(newSessionId, designContract)
288 }
289
290 const projectId = await db.createProject({
291 session_id: newSessionId,
292 title: newTitle,
293 output_path: targetProjectDir,
294 root_path: targetProjectDir
295 })
296
297 for (const page of sourcePages) {
298 const htmlPath = resolveClonedProjectPath(
299 sourceProjectDir,
300 targetProjectDir,
301 page.html_path,
302 `${page.file_slug}.html`
303 )
304 if (!fs.existsSync(htmlPath)) {
305 throw new Error(
306 uiText(
307 locale,
308 `页面文件缺失,无法另存:${page.file_slug}`,
309 `Page file is missing, cannot save as new session: ${page.file_slug}`
310 )
311 )
312 }
313 await db.upsertSessionPage({
314 id: crypto.randomUUID(),
315 sessionId: newSessionId,
316 legacyPageId: page.legacy_page_id,
317 fileSlug: page.file_slug,
318 pageNumber: page.page_number,
319 title: page.title,
320 htmlPath,
321 layoutIntent: page.layout_intent,
322 layoutId: page.layout_id,
323 layoutContractVersion: page.layout_contract_version,
324 status: page.status,
325 error: page.error
326 })
327 }
328
329 const sourceSkeletons = await db.listSourcePageSkeletons(sourceSessionId)
330 for (const skeleton of sourceSkeletons) {
331 await db.upsertSourcePageSkeleton({
332 sessionId: newSessionId,
333 pageNumber: skeleton.page_number,
334 title: skeleton.title,
335 role: skeleton.role,
336 sourceDocumentPath:
337 resolveClonedSessionDocumentPath(
338 sourceProjectDir,
339 targetProjectDir,
340 skeleton.source_document_path,
341 sourceSessionId,
342 newSessionId
343 ) || skeleton.source_document_path,
344 sourceDocumentName: skeleton.source_document_name,
345 sourceHeading: skeleton.source_heading,
346 headingLevel: skeleton.heading_level,
347 lineStart: skeleton.line_start,
348 lineEnd: skeleton.line_end,
349 reason: skeleton.reason,
350 confidence: skeleton.confidence
351 })
352 }
353
354 const sourceMetadata = remapClonedMetadataValue(
355 parseJsonObject(sourceSession.metadata),
356 sourceProjectDir,
357 targetProjectDir,
358 sourceSessionId,
359 newSessionId
360 ) as Record<string, unknown>
361 await db.updateSessionMetadata(newSessionId, {
362 ...sourceMetadata,
363 source: 'session-save-as-new',
364 savedAsNewSessionFrom: sourceSessionId,
365 savedAsNewSessionAt: Date.now(),
366 entryMode:
367 typeof sourceMetadata.entryMode === 'string' && sourceMetadata.entryMode.trim()
368 ? sourceMetadata.entryMode
369 : 'multi_page',
370 indexPath: path.join(targetProjectDir, 'index.html'),
371 projectId
372 })
373 await db.updateProjectStatus(projectId, 'draft')
374 await db.updateSessionStatus(newSessionId, 'completed')
375 await db.updateSessionHistoryPointer({
376 sessionId: newSessionId,
377 operationId: null,
378 commit: null
379 })
380 await ensureHistoryBaselineSafe(db, newSessionId, targetProjectDir)
381 sessionRunStates.delete(newSessionId)
382
383 log.info('[session:saveAsNew] completed', {
384 sourceSessionId,
385 newSessionId,
386 pageCount: sourcePages.length,
387 targetProjectDir
388 })
389
390 return { sessionId: newSessionId }
391 } catch (error) {
392 await fs.promises.rm(targetProjectDir, { recursive: true, force: true }).catch(() => {})
393 await db.deleteSession(newSessionId).catch(() => {})
394 throw error
395 }
396 })
397 }
398
398 lines TYPESCRIPT