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