its whats on the tin; culls raw photos
0
fork

Configure Feed

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

feat: load new files on workspace load

+128 -10
+27 -6
cull/Models/CullSession.swift
··· 350 350 workspace.saveSettings(session: self) 351 351 } 352 352 353 - func openWorkspace(folder: URL) -> Bool { 354 - guard let db = WorkspaceDB(folder: folder) else { return false } 353 + struct WorkspaceResult { 354 + let newPhotos: [Photo] // photos that need analysis + grouping 355 + } 356 + 357 + /// Opens workspace, returns nil if no cached data. Returns new photos that need processing. 358 + func openWorkspace(folder: URL) -> WorkspaceResult? { 359 + guard let db = WorkspaceDB(folder: folder) else { return nil } 355 360 self.workspace = db 356 361 357 - guard db.hasCachedData else { return false } 362 + guard db.hasCachedData else { return nil } 358 363 359 364 let savedPhotos = db.loadPhotos() 360 365 let groupOrder = db.loadGroupOrder() 366 + let savedPaths = Set(savedPhotos.map(\.path)) 361 367 362 368 // Rebuild photos keyed by relative path 363 369 var photosByPath: [String: Photo] = [:] ··· 386 392 photosByPath[saved.path] = photo 387 393 } 388 394 395 + // Scan for new files on disk not in workspace 396 + var newPhotos: [Photo] = [] 397 + if let importResult = try? PhotoImporter.scanFiles(in: folder, recursive: importRecursive) { 398 + for (relativePath, photo) in importResult { 399 + if !savedPaths.contains(relativePath) { 400 + newPhotos.append(photo) 401 + } 402 + } 403 + } 404 + 389 405 // Rebuild groups in saved order 390 406 var photosByGroup: [String: [Photo]] = [:] 391 407 for saved in savedPhotos { ··· 399 415 rebuiltGroups.append(PhotoGroup(photos: photos)) 400 416 } 401 417 402 - // Add any ungrouped photos (shouldn't happen but safety) 418 + // Add any ungrouped saved photos 403 419 let groupedPaths = Set(savedPhotos.compactMap { $0.groupID != nil ? $0.path : nil }) 404 420 let ungrouped = photosByPath.filter { !groupedPaths.contains($0.key) }.map(\.value) 405 421 if !ungrouped.isEmpty { 406 422 rebuiltGroups.append(PhotoGroup(photos: ungrouped)) 407 423 } 408 424 409 - guard !rebuiltGroups.isEmpty else { return false } 425 + // Add new photos as their own group(s) temporarily 426 + if !newPhotos.isEmpty { 427 + rebuiltGroups.append(PhotoGroup(photos: newPhotos)) 428 + } 429 + 430 + guard !rebuiltGroups.isEmpty else { return nil } 410 431 411 432 self.groups = rebuiltGroups 412 433 db.loadSettings(into: self) ··· 417 438 selectedPhotoIndex = min(selectedPhotoIndex, group.photos.count - 1) 418 439 } 419 440 420 - return true 441 + return WorkspaceResult(newPhotos: newPhotos) 421 442 } 422 443 }
+58 -1
cull/Services/PhotoImporter.swift
··· 128 128 } 129 129 130 130 /// Read all metadata from a single CGImageSource open — date, dimensions, file size, paired metadata 131 - nonisolated private static func readAllMetadata(url: URL, pairedURL: URL?, formatter: DateFormatter) -> PhotoMetadata { 131 + nonisolated static func readAllMetadata(url: URL, pairedURL: URL?, formatter: DateFormatter) -> PhotoMetadata { 132 132 var meta = PhotoMetadata() 133 133 134 134 // File size from filesystem ··· 167 167 } 168 168 169 169 return meta 170 + } 171 + 172 + /// Quick file scan — returns (relativePath, Photo) pairs without reading metadata. 173 + /// Used by workspace reload to detect new files. 174 + static func scanFiles(in folder: URL, recursive: Bool) throws -> [(String, Photo)] { 175 + let urls: [URL] 176 + if recursive { 177 + let resourceKeys: Set<URLResourceKey> = [.isRegularFileKey, .contentTypeKey] 178 + guard let enumerator = FileManager.default.enumerator( 179 + at: folder, 180 + includingPropertiesForKeys: Array(resourceKeys), 181 + options: [.skipsHiddenFiles, .skipsPackageDescendants] 182 + ) else { 183 + throw ImportError.cannotReadFolder 184 + } 185 + urls = enumerator.compactMap { $0 as? URL } 186 + } else { 187 + urls = try FileManager.default.contentsOfDirectory( 188 + at: folder, 189 + includingPropertiesForKeys: nil, 190 + options: [.skipsHiddenFiles] 191 + ) 192 + } 193 + 194 + var filesByBasename: [String: [URL]] = [:] 195 + for fileURL in urls { 196 + let ext = fileURL.pathExtension.lowercased() 197 + guard supportedExtensions.contains(ext) else { continue } 198 + let basename = fileURL.deletingPathExtension().lastPathComponent 199 + filesByBasename[basename, default: []].append(fileURL) 200 + } 201 + 202 + var result: [(String, Photo)] = [] 203 + var processed: Set<URL> = [] 204 + 205 + for (_, urls) in filesByBasename { 206 + let rawURLs = urls.filter { isRAWExtension($0.pathExtension) } 207 + let jpegURLs = urls.filter { isJPEGExtension($0.pathExtension) } 208 + 209 + if let rawURL = rawURLs.first, let jpegURL = jpegURLs.first { 210 + let photo = Photo(url: rawURL) 211 + photo.pairedURL = jpegURL 212 + let relativePath = rawURL.relativePath(from: folder) 213 + result.append((relativePath, photo)) 214 + processed.insert(rawURL) 215 + processed.insert(jpegURL) 216 + } 217 + 218 + for url in urls where !processed.contains(url) { 219 + let photo = Photo(url: url) 220 + let relativePath = url.relativePath(from: folder) 221 + result.append((relativePath, photo)) 222 + processed.insert(url) 223 + } 224 + } 225 + 226 + return result 170 227 } 171 228 172 229 static func isRAWExtension(_ ext: String) -> Bool {
+43 -3
cull/Views/ContentView.swift
··· 55 55 Task { 56 56 do { 57 57 // Try loading from workspace first 58 - if s.openWorkspace(folder: url) { 59 - await MainActor.run { s.importStatus = "Loading from workspace..." } 58 + if let wsResult = s.openWorkspace(folder: url) { 60 59 let allPhotos = s.allPhotos 60 + let newPhotos = wsResult.newPhotos 61 61 62 - // Still need to load thumbnails and previews 62 + if !newPhotos.isEmpty { 63 + await MainActor.run { s.importStatus = "Analyzing \(newPhotos.count) new photos..." } 64 + // Read metadata for new photos 65 + let formatter = DateFormatter() 66 + formatter.dateFormat = "yyyy:MM:dd HH:mm:ss" 67 + formatter.locale = Locale(identifier: "en_US_POSIX") 68 + let metaInputs: [(Int, URL, URL?)] = newPhotos.enumerated().map { (i, p) in 69 + (i, p.url, p.pairedURL) 70 + } 71 + let results = await withTaskGroup(of: (Int, PhotoImporter.PhotoMetadata).self, returning: [(Int, PhotoImporter.PhotoMetadata)].self) { group in 72 + for (index, photoURL, pairedURL) in metaInputs { 73 + group.addTask { 74 + let meta = PhotoImporter.readAllMetadata(url: photoURL, pairedURL: pairedURL, formatter: formatter) 75 + return (index, meta) 76 + } 77 + } 78 + var collected: [(Int, PhotoImporter.PhotoMetadata)] = [] 79 + for await result in group { collected.append(result) } 80 + return collected 81 + } 82 + for (index, meta) in results { 83 + let photo = newPhotos[index] 84 + photo.captureDate = meta.captureDate 85 + photo.pixelWidth = meta.pixelWidth 86 + photo.pixelHeight = meta.pixelHeight 87 + photo.fileSize = meta.fileSize 88 + photo.pairedPixelWidth = meta.pairedPixelWidth 89 + photo.pairedPixelHeight = meta.pairedPixelHeight 90 + photo.pairedFileSize = meta.pairedFileSize 91 + } 92 + 93 + // Analyze new photos 94 + for photo in newPhotos { 95 + await QualityAnalyzer.analyze(photo: photo) 96 + } 97 + } 98 + 99 + // Load thumbnails and previews 63 100 await MainActor.run { s.importStatus = "Loading thumbnails..." } 64 101 await c.preloadAllThumbnails(photos: allPhotos) { p in 65 102 await MainActor.run { ··· 84 121 await MainActor.run { 85 122 s.importProgress = 1.0 86 123 s.isImporting = false 124 + if !newPhotos.isEmpty { 125 + s.saveWorkspace() 126 + } 87 127 } 88 128 return 89 129 }