返回 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
247 try {
248 await copyDirectoryForNewSession(sourceProjectDir, targetProjectDir)
249 await replaceSessionIdInClonedTextFiles(targetProjectDir, sourceSessionId, newSessionId)
250 await ensureSessionAssets(targetProjectDir)
251 await createSessionMasterIfMissing(targetProjectDir)
252 const referenceDocumentPath = resolveClonedSessionDocumentPath(
253 sourceProjectDir,
254 targetProjectDir,
255 sourceSession.referenceDocumentPath ?? sourceSession.reference_document_path,
256 sourceSessionId,
257 newSessionId
258 )
259 const slideSize = requireSessionSlideSize(sourceSession)
260
261 await db.createSession({
262 id: newSessionId,
263 title: newTitle,
264 topic: sourceSession.topic || baseTitle,
265 styleId: sourceSession.styleId ?? undefined,
266 pageCount: sourcePages.length,
267 slideSizeId: slideSize.id,
268 slideWidth: slideSize.width,
269 slideHeight: slideSize.height,
270 referenceDocumentPath,
271 provider: sourceProvider,
272 model: sourceModel
273 })
274 await db.copySessionStyleSnapshot(sourceSessionId, newSessionId)
275
276 const designContract = parseOptionalJson(sourceSession.designContract)
277 if (designContract) {
278 await db.updateSessionDesignContract(newSessionId, designContract)
279 }
280
281 const projectId = await db.createProject({
282 session_id: newSessionId,
283 title: newTitle,
284 output_path: targetProjectDir,
285 root_path: targetProjectDir
286 })
287
288 for (const page of sourcePages) {
289 const htmlPath = resolveClonedProjectPath(
290 sourceProjectDir,
291 targetProjectDir,
292 page.html_path,
293 `${page.file_slug}.html`
294 )
295 if (!fs.existsSync(htmlPath)) {
296 throw new Error(
297 uiText(
298 locale,
299 `页面文件缺失,无法另存:${page.file_slug}`,
300 `Page file is missing, cannot save as new session: ${page.file_slug}`
301 )
302 )
303 }
304 await db.upsertSessionPage({
305 id: crypto.randomUUID(),
306 sessionId: newSessionId,
307 legacyPageId: page.legacy_page_id,
308 fileSlug: page.file_slug,
309 pageNumber: page.page_number,
310 title: page.title,
311 htmlPath,
312 status: page.status,
313 error: page.error
314 })
315 }
316
317 const sourceSkeletons = await db.listSourcePageSkeletons(sourceSessionId)
318 for (const skeleton of sourceSkeletons) {
319 await db.upsertSourcePageSkeleton({
320 sessionId: newSessionId,
321 pageNumber: skeleton.page_number,
322 title: skeleton.title,
323 role: skeleton.role,
324 sourceDocumentPath:
325 resolveClonedSessionDocumentPath(
326 sourceProjectDir,
327 targetProjectDir,
328 skeleton.source_document_path,
329 sourceSessionId,
330 newSessionId
331 ) || skeleton.source_document_path,
332 sourceDocumentName: skeleton.source_document_name,
333 sourceHeading: skeleton.source_heading,
334 headingLevel: skeleton.heading_level,
335 lineStart: skeleton.line_start,
336 lineEnd: skeleton.line_end,
337 reason: skeleton.reason,
338 confidence: skeleton.confidence
339 })
340 }
341
342 const sourceMetadata = remapClonedMetadataValue(
343 parseJsonObject(sourceSession.metadata),
344 sourceProjectDir,
345 targetProjectDir,
346 sourceSessionId,
347 newSessionId
348 ) as Record<string, unknown>
349 await db.updateSessionMetadata(newSessionId, {
350 ...sourceMetadata,
351 source: 'session-save-as-new',
352 savedAsNewSessionFrom: sourceSessionId,
353 savedAsNewSessionAt: Date.now(),
354 entryMode:
355 typeof sourceMetadata.entryMode === 'string' && sourceMetadata.entryMode.trim()
356 ? sourceMetadata.entryMode
357 : 'multi_page',
358 indexPath: path.join(targetProjectDir, 'index.html'),
359 projectId
360 })
361 await db.updateProjectStatus(projectId, 'draft')
362 await db.updateSessionStatus(newSessionId, 'completed')
363 await db.updateSessionHistoryPointer({
364 sessionId: newSessionId,
365 operationId: null,
366 commit: null
367 })
368 await ensureHistoryBaselineSafe(db, newSessionId, targetProjectDir)
369 sessionRunStates.delete(newSessionId)
370
371 log.info('[session:saveAsNew] completed', {
372 sourceSessionId,
373 newSessionId,
374 pageCount: sourcePages.length,
375 targetProjectDir
376 })
377
378 return { sessionId: newSessionId }
379 } catch (error) {
380 await fs.promises.rm(targetProjectDir, { recursive: true, force: true }).catch(() => {})
381 await db.deleteSession(newSessionId).catch(() => {})
382 throw error
383 }
384 })
385 }
386
386 lines TYPESCRIPT