Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1import type { BlobRef } from "@atproto/api"; 2import type { Directory, Entry, File } from "@wisp/lexicons/types/place/wisp/fs"; 3 4export interface UploadedFile { 5 name: string; 6 content: Buffer; 7 mimeType: string; 8 size: number; 9 compressed?: boolean; 10 base64Encoded?: boolean; 11 originalMimeType?: string; 12} 13 14export interface FileUploadResult { 15 hash: string; 16 blobRef: BlobRef; 17 encoding?: 'gzip'; 18 mimeType?: string; 19 base64?: boolean; 20} 21 22export interface ProcessedDirectory { 23 directory: Directory; 24 fileCount: number; 25} 26 27/** 28 * Process uploaded files into a directory structure 29 */ 30export function processUploadedFiles(files: UploadedFile[]): ProcessedDirectory { 31 const entries: Entry[] = []; 32 let fileCount = 0; 33 34 // Group files by directory 35 const directoryMap = new Map<string, UploadedFile[]>(); 36 37 for (const file of files) { 38 // Skip undefined/null files (defensive) 39 if (!file || !file.name) { 40 console.error('Skipping undefined or invalid file in processUploadedFiles'); 41 continue; 42 } 43 44 // Remove any base folder name from the path 45 const normalizedPath = file.name.replace(/^[^\/]*\//, ''); 46 47 // Skip files in .git directories 48 if (normalizedPath.startsWith('.git/') || normalizedPath === '.git') { 49 continue; 50 } 51 52 const parts = normalizedPath.split('/'); 53 54 if (parts.length === 1) { 55 // Root level file 56 entries.push({ 57 name: parts[0]!, 58 node: { 59 $type: 'place.wisp.fs#file' as const, 60 type: 'file' as const, 61 blob: undefined as any // Will be filled after upload 62 } 63 }); 64 fileCount++; 65 } else { 66 // File in subdirectory 67 const dirPath = parts.slice(0, -1).join('/'); 68 if (!directoryMap.has(dirPath)) { 69 directoryMap.set(dirPath, []); 70 } 71 directoryMap.get(dirPath)!.push({ 72 ...file, 73 name: normalizedPath 74 }); 75 } 76 } 77 78 // Process subdirectories 79 for (const [dirPath, dirFiles] of directoryMap) { 80 const dirEntries: Entry[] = []; 81 82 for (const file of dirFiles) { 83 const fileName = file.name.split('/').pop()!; 84 dirEntries.push({ 85 name: fileName, 86 node: { 87 $type: 'place.wisp.fs#file' as const, 88 type: 'file' as const, 89 blob: undefined as any // Will be filled after upload 90 } 91 }); 92 fileCount++; 93 } 94 95 // Build nested directory structure 96 const pathParts = dirPath.split('/'); 97 let currentEntries = entries; 98 99 for (let i = 0; i < pathParts.length; i++) { 100 const part = pathParts[i]; 101 const isLast = i === pathParts.length - 1; 102 103 let existingEntry = currentEntries.find(e => e.name === part); 104 105 if (!existingEntry) { 106 const newDir = { 107 $type: 'place.wisp.fs#directory' as const, 108 type: 'directory' as const, 109 entries: isLast ? dirEntries : [] 110 }; 111 112 existingEntry = { 113 name: part!, 114 node: newDir 115 }; 116 currentEntries.push(existingEntry); 117 } else if ('entries' in existingEntry.node && isLast) { 118 (existingEntry.node as any).entries.push(...dirEntries); 119 } 120 121 if (existingEntry && 'entries' in existingEntry.node) { 122 currentEntries = (existingEntry.node as any).entries; 123 } 124 } 125 } 126 127 const result = { 128 directory: { 129 $type: 'place.wisp.fs#directory' as const, 130 type: 'directory' as const, 131 entries 132 }, 133 fileCount 134 }; 135 136 return result; 137} 138 139/** 140 * Update file blobs in directory structure after upload 141 * Uses path-based matching to correctly match files in nested directories 142 * Filters out files that were not successfully uploaded 143 */ 144export function updateFileBlobs( 145 directory: Directory, 146 uploadResults: FileUploadResult[], 147 filePaths: string[], 148 currentPath: string = '', 149 successfulPaths?: Set<string> 150): Directory { 151 const updatedEntries = directory.entries.map(entry => { 152 if ('type' in entry.node && entry.node.type === 'file') { 153 // Build the full path for this file 154 const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; 155 156 // If successfulPaths is provided, skip files that weren't successfully uploaded 157 if (successfulPaths && !successfulPaths.has(fullPath)) { 158 return null; // Filter out failed files 159 } 160 161 // Find exact match in filePaths (need to handle normalized paths) 162 const fileIndex = filePaths.findIndex((path) => { 163 // Normalize both paths by removing leading base folder 164 const normalizedUploadPath = path.replace(/^[^\/]*\//, ''); 165 const normalizedEntryPath = fullPath; 166 return normalizedUploadPath === normalizedEntryPath || path === fullPath; 167 }); 168 169 if (fileIndex !== -1 && uploadResults[fileIndex]) { 170 const result = uploadResults[fileIndex]; 171 const blobRef = result.blobRef; 172 173 return { 174 ...entry, 175 node: { 176 $type: 'place.wisp.fs#file' as const, 177 type: 'file' as const, 178 blob: blobRef, 179 ...(result.encoding && { encoding: result.encoding }), 180 ...(result.mimeType && { mimeType: result.mimeType }), 181 ...(result.base64 && { base64: result.base64 }) 182 } 183 }; 184 } else { 185 console.error(`Could not find blob for file: ${fullPath}`); 186 return null; // Filter out files without blobs 187 } 188 } else if ('type' in entry.node && entry.node.type === 'directory') { 189 const dirPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; 190 return { 191 ...entry, 192 node: updateFileBlobs(entry.node as Directory, uploadResults, filePaths, dirPath, successfulPaths) 193 }; 194 } 195 return entry; 196 }).filter(entry => entry !== null) as Entry[]; // Remove null entries (failed files) 197 198 const result = { 199 $type: 'place.wisp.fs#directory' as const, 200 type: 'directory' as const, 201 entries: updatedEntries 202 }; 203 204 return result; 205} 206 207/** 208 * Count files in a directory tree 209 */ 210export function countFilesInDirectory(directory: Directory): number { 211 let count = 0; 212 for (const entry of directory.entries) { 213 if ('type' in entry.node && entry.node.type === 'file') { 214 count++; 215 } else if ('type' in entry.node && entry.node.type === 'directory') { 216 count += countFilesInDirectory(entry.node as Directory); 217 } 218 } 219 return count; 220} 221 222/** 223 * Recursively collect file CIDs from entries for incremental update tracking 224 */ 225export function collectFileCidsFromEntries(entries: Entry[], pathPrefix: string, fileCids: Record<string, string>): void { 226 for (const entry of entries) { 227 const currentPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name; 228 const node = entry.node; 229 230 if ('type' in node && node.type === 'directory' && 'entries' in node) { 231 collectFileCidsFromEntries(node.entries, currentPath, fileCids); 232 } else if ('type' in node && node.type === 'file' && 'blob' in node) { 233 const fileNode = node as File; 234 // Extract CID from blob ref 235 if (fileNode.blob && fileNode.blob.ref) { 236 const cid = fileNode.blob.ref.toString(); 237 fileCids[currentPath] = cid; 238 } 239 } 240 } 241}