A personal website powered by Astro and ATProto
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

at dev 303 lines 10 kB view raw
1import { AtprotoBrowser } from '../atproto/atproto-browser'; 2import { loadConfig } from '../config/site'; 3import fs from 'fs/promises'; 4import path from 'path'; 5 6export interface CollectionType { 7 name: string; 8 description: string; 9 service: string; 10 sampleRecords: any[]; 11 generatedTypes: string; 12 $types: string[]; 13} 14 15export interface BuildTimeDiscovery { 16 collections: CollectionType[]; 17 totalCollections: number; 18 totalRecords: number; 19 generatedAt: string; 20 repository: { 21 handle: string; 22 did: string; 23 recordCount: number; 24 }; 25} 26 27export class CollectionDiscovery { 28 private browser: AtprotoBrowser; 29 private config: any; 30 31 constructor() { 32 this.config = loadConfig(); 33 this.browser = new AtprotoBrowser(); 34 } 35 36 // Discover all collections and generate types 37 async discoverCollections(identifier: string): Promise<BuildTimeDiscovery> { 38 console.log('🔍 Starting collection discovery for:', identifier); 39 40 try { 41 // Get repository info 42 const repoInfo = await this.browser.getRepoInfo(identifier); 43 if (!repoInfo) { 44 throw new Error(`Could not get repository info for: ${identifier}`); 45 } 46 47 console.log('📊 Repository info:', { 48 handle: repoInfo.handle, 49 did: repoInfo.did, 50 collections: repoInfo.collections.length, 51 recordCount: repoInfo.recordCount 52 }); 53 54 const collections: CollectionType[] = []; 55 let totalRecords = 0; 56 57 // Process each collection 58 for (const collectionName of repoInfo.collections) { 59 console.log(`📦 Processing collection: ${collectionName}`); 60 61 const collectionType = await this.processCollection(identifier, collectionName); 62 if (collectionType) { 63 collections.push(collectionType); 64 totalRecords += collectionType.sampleRecords.length; 65 } 66 } 67 68 const discovery: BuildTimeDiscovery = { 69 collections, 70 totalCollections: collections.length, 71 totalRecords, 72 generatedAt: new Date().toISOString(), 73 repository: { 74 handle: repoInfo.handle, 75 did: repoInfo.did, 76 recordCount: repoInfo.recordCount 77 } 78 }; 79 80 console.log(`✅ Discovery complete: ${collections.length} collections, ${totalRecords} records`); 81 return discovery; 82 83 } catch (error) { 84 console.error('Error discovering collections:', error); 85 throw error; 86 } 87 } 88 89 // Process a single collection 90 private async processCollection(identifier: string, collectionName: string): Promise<CollectionType | null> { 91 try { 92 // Get records from collection 93 const records = await this.browser.getCollectionRecords(identifier, collectionName, 10); 94 if (!records || records.records.length === 0) { 95 console.log(`⚠️ No records found in collection: ${collectionName}`); 96 return null; 97 } 98 99 // Group records by $type 100 const recordsByType = new Map<string, any[]>(); 101 for (const record of records.records) { 102 const $type = record.$type || 'unknown'; 103 if (!recordsByType.has($type)) { 104 recordsByType.set($type, []); 105 } 106 recordsByType.get($type)!.push(record.value); 107 } 108 109 // Generate types for each $type 110 const generatedTypes: string[] = []; 111 const $types: string[] = []; 112 113 for (const [$type, typeRecords] of recordsByType) { 114 if ($type === 'unknown') continue; 115 116 $types.push($type); 117 const typeDefinition = this.generateTypeDefinition($type, typeRecords); 118 generatedTypes.push(typeDefinition); 119 } 120 121 // Create collection type 122 const collectionType: CollectionType = { 123 name: collectionName, 124 description: this.getCollectionDescription(collectionName), 125 service: this.inferService(collectionName), 126 sampleRecords: records.records.slice(0, 3).map(r => r.value), 127 generatedTypes: generatedTypes.join('\n\n'), 128 $types 129 }; 130 131 console.log(`✅ Processed collection ${collectionName}: ${$types.length} types`); 132 return collectionType; 133 134 } catch (error) { 135 console.error(`Error processing collection ${collectionName}:`, error); 136 return null; 137 } 138 } 139 140 // Generate TypeScript type definition 141 private generateTypeDefinition($type: string, records: any[]): string { 142 if (records.length === 0) return ''; 143 144 // Analyze the first record to understand the structure 145 const sampleRecord = records[0]; 146 const properties = this.extractProperties(sampleRecord); 147 148 // Generate interface 149 const interfaceName = this.$typeToInterfaceName($type); 150 let typeDefinition = `export interface ${interfaceName} {\n`; 151 152 // Add $type property 153 typeDefinition += ` $type: '${$type}';\n`; 154 155 // Add other properties 156 for (const [key, value] of Object.entries(properties)) { 157 const type = this.inferType(value); 158 typeDefinition += ` ${key}?: ${type};\n`; 159 } 160 161 typeDefinition += '}\n'; 162 163 return typeDefinition; 164 } 165 166 // Extract properties from a record 167 private extractProperties(obj: any, maxDepth: number = 3, currentDepth: number = 0): Record<string, any> { 168 if (currentDepth >= maxDepth || !obj || typeof obj !== 'object') { 169 return {}; 170 } 171 172 const properties: Record<string, any> = {}; 173 174 for (const [key, value] of Object.entries(obj)) { 175 if (key === '$type') continue; // Skip $type as it's handled separately 176 177 if (typeof value === 'object' && value !== null && !Array.isArray(value)) { 178 // Nested object 179 const nestedProps = this.extractProperties(value, maxDepth, currentDepth + 1); 180 if (Object.keys(nestedProps).length > 0) { 181 properties[key] = nestedProps; 182 } 183 } else { 184 // Simple property 185 properties[key] = value; 186 } 187 } 188 189 return properties; 190 } 191 192 // Infer TypeScript type from value 193 private inferType(value: any): string { 194 if (value === null) return 'null'; 195 if (value === undefined) return 'undefined'; 196 197 const type = typeof value; 198 199 switch (type) { 200 case 'string': 201 return 'string'; 202 case 'number': 203 return 'number'; 204 case 'boolean': 205 return 'boolean'; 206 case 'object': 207 if (Array.isArray(value)) { 208 if (value.length === 0) return 'any[]'; 209 const elementType = this.inferType(value[0]); 210 return `${elementType}[]`; 211 } 212 return 'Record<string, any>'; 213 default: 214 return 'any'; 215 } 216 } 217 218 // Convert $type to interface name 219 private $typeToInterfaceName($type: string): string { 220 // Convert app.bsky.feed.post to AppBskyFeedPost 221 return $type 222 .split('.') 223 .map(part => part.charAt(0).toUpperCase() + part.slice(1)) 224 .join(''); 225 } 226 227 // Get collection description 228 private getCollectionDescription(collectionName: string): string { 229 const descriptions: Record<string, string> = { 230 'app.bsky.feed.post': 'Bluesky posts', 231 'app.bsky.actor.profile': 'Bluesky profile information', 232 'app.bsky.feed.generator': 'Bluesky custom feeds', 233 'app.bsky.graph.follow': 'Bluesky follow relationships', 234 'app.bsky.graph.block': 'Bluesky block relationships', 235 'app.bsky.feed.like': 'Bluesky like records', 236 'app.bsky.feed.repost': 'Bluesky repost records', 237 'social.grain.gallery': 'Grain.social image galleries', 238 'grain.social.feed.gallery': 'Grain.social galleries', 239 'grain.social.feed.post': 'Grain.social posts', 240 'grain.social.actor.profile': 'Grain.social profile information', 241 }; 242 243 return descriptions[collectionName] || `${collectionName} records`; 244 } 245 246 // Infer service from collection name 247 private inferService(collectionName: string): string { 248 if (collectionName.startsWith('grain.social') || collectionName.startsWith('social.grain')) { 249 return 'grain.social'; 250 } 251 if (collectionName.startsWith('app.bsky')) { 252 return 'bsky.app'; 253 } 254 if (collectionName.startsWith('sh.tangled')) { 255 return 'sh.tangled'; 256 } 257 return 'unknown'; 258 } 259 260 // Save discovery results to file 261 async saveDiscoveryResults(discovery: BuildTimeDiscovery, outputPath: string): Promise<void> { 262 try { 263 // Create output directory if it doesn't exist 264 const outputDir = path.dirname(outputPath); 265 await fs.mkdir(outputDir, { recursive: true }); 266 267 // Save discovery metadata 268 const metadataPath = outputPath.replace('.ts', '.json'); 269 await fs.writeFile(metadataPath, JSON.stringify(discovery, null, 2)); 270 271 // Generate TypeScript file with all types 272 let typesContent = '// Auto-generated types from collection discovery\n'; 273 typesContent += `// Generated at: ${discovery.generatedAt}\n`; 274 typesContent += `// Repository: ${discovery.repository.handle} (${discovery.repository.did})\n`; 275 typesContent += `// Collections: ${discovery.totalCollections}, Records: ${discovery.totalRecords}\n\n`; 276 277 // Add all generated types 278 for (const collection of discovery.collections) { 279 typesContent += `// Collection: ${collection.name}\n`; 280 typesContent += `// Service: ${collection.service}\n`; 281 typesContent += `// Types: ${collection.$types.join(', ')}\n`; 282 typesContent += collection.generatedTypes; 283 typesContent += '\n\n'; 284 } 285 286 // Add union type for all discovered types 287 const allTypes = discovery.collections.flatMap(c => c.$types); 288 if (allTypes.length > 0) { 289 typesContent += '// Union type for all discovered types\n'; 290 typesContent += `export type DiscoveredTypes = ${allTypes.map(t => `'${t}'`).join(' | ')};\n\n`; 291 } 292 293 await fs.writeFile(outputPath, typesContent); 294 295 console.log(`💾 Saved discovery results to: ${outputPath}`); 296 console.log(`📊 Generated ${discovery.totalCollections} collection types`); 297 298 } catch (error) { 299 console.error('Error saving discovery results:', error); 300 throw error; 301 } 302 } 303}