返回 oh-my-ppt
source-documents.ts
根目录 / src / main / generation / source-documents.ts
1 import fs from 'fs'
2 import path from 'path'
3 import { createHash } from 'crypto'
4 import type { GenerationContext } from './context'
5 import type { GenerateMode } from './types'
6
7 /**
8 * Resolve only the durable session reference document. Transient attachments
9 * deliberately stay out of this contract because they must not enable the
10 * page-level Reference Range Content Boundary mode.
11 */
12 export const resolveSessionReferenceDocumentPath = (
13 projectDir: string,
14 sessionRecord: Record<string, unknown>
15 ): string | null => {
16 const rawReferenceDocumentPath =
17 sessionRecord.referenceDocumentPath ?? sessionRecord.reference_document_path
18 const referenceDocumentPath =
19 typeof rawReferenceDocumentPath === 'string' ? rawReferenceDocumentPath.trim() : ''
20 if (!referenceDocumentPath) return null
21
22 const docsDir = path.resolve(projectDir, 'docs')
23 const filePath = referenceDocumentPath.startsWith('/docs/')
24 ? path.resolve(projectDir, referenceDocumentPath.replace(/^\/+/, ''))
25 : path.isAbsolute(referenceDocumentPath)
26 ? path.resolve(referenceDocumentPath)
27 : path.resolve(docsDir, referenceDocumentPath)
28 const relativeToDocs = path.relative(docsDir, filePath)
29 if (!relativeToDocs || relativeToDocs.startsWith('..') || path.isAbsolute(relativeToDocs)) {
30 return null
31 }
32
33 try {
34 if (!fs.statSync(filePath).isFile()) return null
35 } catch {
36 return null
37 }
38
39 return `/docs/${relativeToDocs.split(path.sep).join('/')}`
40 }
41
42 const sanitizeAttachmentFileName = (sourcePath: string): { stem: string; extension: string } => {
43 const originalName = path.basename(sourcePath)
44 const extension = path.extname(originalName).replace(/[\\/:"*?<>|]+/g, '-')
45 const rawStem = originalName.slice(0, Math.max(0, originalName.length - extension.length))
46 const stem = rawStem.replace(/[\\/:"*?<>|]+/g, '-').trim() || 'attachment'
47 return { stem, extension }
48 }
49
50 const sha256 = (content: Buffer): string => createHash('sha256').update(content).digest('hex')
51
52 const copyAttachmentWithContentHash = async (args: {
53 sourcePath: string
54 sessionDocsDir: string
55 }): Promise<string> => {
56 const content = await fs.promises.readFile(args.sourcePath)
57 const contentHash = sha256(content)
58 const { stem, extension } = sanitizeAttachmentFileName(args.sourcePath)
59 const targetStem = `${stem}--${contentHash.slice(0, 16)}`
60
61 for (let collisionIndex = 0; collisionIndex < 10_000; collisionIndex += 1) {
62 const collisionSuffix = collisionIndex === 0 ? '' : `--${collisionIndex + 1}`
63 const targetName = `${targetStem}${collisionSuffix}${extension}`
64 const targetPath = path.join(args.sessionDocsDir, targetName)
65 if (path.resolve(args.sourcePath) === path.resolve(targetPath)) return `/docs/${targetName}`
66
67 try {
68 const existingHash = sha256(await fs.promises.readFile(targetPath))
69 if (existingHash === contentHash) return `/docs/${targetName}`
70 continue
71 } catch (error) {
72 if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
73 }
74
75 try {
76 await fs.promises.copyFile(args.sourcePath, targetPath, fs.constants.COPYFILE_EXCL)
77 return `/docs/${targetName}`
78 } catch (error) {
79 if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
80 }
81 }
82
83 throw new Error(`Unable to create a collision-safe attachment path for ${path.basename(args.sourcePath)}`)
84 }
85
86 export async function resolveSourceDocuments(
87 ctx: Pick<GenerationContext, 'localFiles'>,
88 args: {
89 sessionId: string
90 projectDir: string
91 rawDocPaths: string[]
92 // Kept as an explicit entry-mode contract for callers. The current resolver
93 // uses the same session reference/raw-doc behavior for every mode.
94 mode: GenerateMode
95 sessionRecord: Record<string, unknown>
96 }
97 ): Promise<string[]> {
98 const { sessionId, projectDir, rawDocPaths, sessionRecord } = args
99 const { assertPathInAllowedRoots } = ctx.localFiles
100 const referenceDocumentPath = resolveSessionReferenceDocumentPath(projectDir, sessionRecord)
101
102 const sessionDocsDir = path.join(projectDir, 'docs')
103 const sourceDocumentPaths: string[] = []
104 const appendSourceDocumentPath = (docPath: string | null): void => {
105 if (!docPath || sourceDocumentPaths.includes(docPath)) return
106 sourceDocumentPaths.push(docPath)
107 }
108 appendSourceDocumentPath(referenceDocumentPath)
109
110 if (rawDocPaths.length > 0) {
111 await fs.promises.mkdir(sessionDocsDir, { recursive: true })
112 for (const candidate of rawDocPaths) {
113 const sourcePath = await assertPathInAllowedRoots({
114 filePath: candidate,
115 mode: 'read',
116 sessionId
117 })
118 appendSourceDocumentPath(
119 await copyAttachmentWithContentHash({ sourcePath, sessionDocsDir })
120 )
121 }
122 return sourceDocumentPaths
123 }
124
125 if (sourceDocumentPaths.length > 0) await fs.promises.mkdir(sessionDocsDir, { recursive: true })
126 return sourceDocumentPaths
127 }
128
128 lines TYPESCRIPT