FileExplorerAndEditor.utils.ts206 lines · main
1import { BlobReader, BlobWriter, TextWriter, ZipReader } from '@zip.js/zip.js'
2
3import { FileAction, type FileActionResult, type FileData } from './FileExplorerAndEditor.types'
4import { formatBytes } from '@/lib/helpers'
5
6// Configuration for zip file extraction
7export const ZIP_EXTRACTION_CONFIG = {
8 // Maximum total extracted size: 50MB (reasonable for edge functions)
9 MAX_TOTAL_EXTRACTED_SIZE: 50 * 1024 * 1024, // 50MB
10
11 // Maximum individual file size: 10MB
12 MAX_INDIVIDUAL_FILE_SIZE: 10 * 1024 * 1024, // 10MB
13} as const
14
15export const isBinaryFile = (fileName: string): boolean => {
16 const extension = fileName.split('.').pop()?.toLowerCase()
17 const binaryExtensions = [
18 'wasm',
19 'jpg',
20 'jpeg',
21 'png',
22 'gif',
23 'bmp',
24 'ico',
25 'svg',
26 'mp3',
27 'mp4',
28 'avi',
29 'mov',
30 'zip',
31 'rar',
32 '7z',
33 'tar',
34 'gz',
35 'bz2',
36 'pdf',
37 ]
38 return binaryExtensions.includes(extension || '')
39}
40
41export const getLanguageFromFileName = (fileName: string): string => {
42 const extension = fileName.split('.').pop()?.toLowerCase()
43 switch (extension) {
44 case 'ts':
45 case 'tsx':
46 return 'typescript'
47 case 'js':
48 case 'jsx':
49 return 'javascript'
50 case 'json':
51 return 'json'
52 case 'html':
53 return 'html'
54 case 'css':
55 return 'css'
56 case 'md':
57 return 'markdown'
58 case 'csv':
59 return 'csv'
60 default:
61 return 'plaintext' // Default to plaintext
62 }
63}
64
65/**
66 * Check if a file is a zip archive based on file extension
67 */
68export const isZipFile = (fileName: string): boolean => {
69 const extension = fileName.split('.').pop()?.toLowerCase()
70 return extension === 'zip'
71}
72
73/**
74 * Extract files from a zip archive
75 * Returns an array of extracted files with their full paths as names (flat structure)
76 */
77export const extractZipFile = async (
78 zipFile: File,
79 onProgress?: (current: number, total: number) => void
80): Promise<{ name: string; content: string; size: number }[]> => {
81 const zipReader = new ZipReader(new BlobReader(zipFile))
82 const entries = await zipReader.getEntries()
83
84 const extractedFiles: { name: string; content: string; size: number }[] = []
85 const skippedFiles: string[] = []
86 const oversizedFiles: string[] = []
87 const failedFiles: string[] = []
88
89 let totalExtractedSize = 0
90
91 // Filter out directories and process files
92 const fileEntries = entries.filter((entry) => !entry.directory)
93
94 for (let i = 0; i < fileEntries.length; i++) {
95 const entry = fileEntries[i]
96 const fileName = entry.filename
97
98 // Report progress
99 if (onProgress) {
100 onProgress(i + 1, fileEntries.length)
101 }
102
103 // Skip hidden files and system files
104 const pathParts = fileName.split('/')
105 const hasHiddenFolder = pathParts.some((part) => part.startsWith('.') || part === '__MACOSX')
106 if (hasHiddenFolder || fileName === '.DS_Store') {
107 skippedFiles.push(fileName)
108 continue
109 }
110
111 // Check individual file size
112 const uncompressedSize = entry.uncompressedSize
113
114 // Guard against undefined/NaN uncompressedSize to prevent bypass of size validation
115 if (uncompressedSize === undefined || Number.isNaN(uncompressedSize)) {
116 oversizedFiles.push(`${fileName} (unknown size - metadata unavailable)`)
117 continue
118 }
119
120 if (uncompressedSize > ZIP_EXTRACTION_CONFIG.MAX_INDIVIDUAL_FILE_SIZE) {
121 oversizedFiles.push(`${fileName} (${formatBytes(uncompressedSize)})`)
122 continue
123 }
124
125 // Check total extracted size
126 if (totalExtractedSize + uncompressedSize > ZIP_EXTRACTION_CONFIG.MAX_TOTAL_EXTRACTED_SIZE) {
127 throw new Error(
128 `Total extracted size would exceed ${formatBytes(ZIP_EXTRACTION_CONFIG.MAX_TOTAL_EXTRACTED_SIZE)}. ` +
129 `Current: ${formatBytes(totalExtractedSize)}, ` +
130 `Attempted to add: ${formatBytes(uncompressedSize)}`
131 )
132 }
133
134 // Extract file content
135 try {
136 // Skip if entry is a directory or doesn't have getData method
137 if (entry.directory || !entry.getData) {
138 console.warn(`Entry ${fileName} is a directory or has no getData method, skipping`)
139 failedFiles.push(fileName)
140 continue
141 }
142
143 let content: string
144 if (isBinaryFile(fileName)) {
145 // For binary files, read as blob and convert to binary string
146 const blob = await entry.getData(new BlobWriter())
147 const arrayBuffer = await blob.arrayBuffer()
148 const bytes = new Uint8Array(arrayBuffer)
149 content = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
150 } else {
151 // For text files, read as text
152 content = await entry.getData(new TextWriter())
153 }
154
155 extractedFiles.push({
156 name: fileName, // Keep full path as file name (flat structure)
157 content,
158 size: uncompressedSize,
159 })
160
161 totalExtractedSize += uncompressedSize
162 } catch (error) {
163 console.error(`Failed to extract file ${fileName}:`, error)
164 failedFiles.push(fileName)
165 }
166 }
167
168 await zipReader.close()
169
170 // Throw error if no valid files found
171 if (extractedFiles.length === 0) {
172 const reasons: string[] = []
173 if (skippedFiles.length > 0) {
174 reasons.push(`${skippedFiles.length} hidden/system files`)
175 }
176 if (oversizedFiles.length > 0) {
177 reasons.push(`${oversizedFiles.length} oversized files`)
178 }
179 if (failedFiles.length > 0) {
180 reasons.push(`${failedFiles.length} files failed to extract`)
181 }
182 throw new Error(
183 `No valid files found in zip archive. ${reasons.length > 0 ? 'Skipped: ' + reasons.join(', ') : ''}`
184 )
185 }
186
187 return extractedFiles
188}
189
190export const getFileAction = (
191 fileName: string,
192 existingFiles: FileData[],
193 newFiles: FileData[]
194): FileActionResult => {
195 const existingIndex = existingFiles.findIndex((f) => f.name === fileName)
196 if (existingIndex !== -1) {
197 return { action: FileAction.REPLACE_EXISTING, index: existingIndex }
198 }
199
200 const newIndex = newFiles.findIndex((f) => f.name === fileName)
201 if (newIndex !== -1) {
202 return { action: FileAction.REPLACE_NEW, index: newIndex }
203 }
204
205 return { action: FileAction.CREATE_NEW }
206}