diff --git a/cull/Models/CullSession.swift b/cull/Models/CullSession.swift index 3b4fc63..a53c20e 100644 --- a/cull/Models/CullSession.swift +++ b/cull/Models/CullSession.swift @@ -350,14 +350,20 @@ final class CullSession { workspace.saveSettings(session: self) } - func openWorkspace(folder: URL) -> Bool { - guard let db = WorkspaceDB(folder: folder) else { return false } + struct WorkspaceResult { + let newPhotos: [Photo] // photos that need analysis + grouping + } + + /// Opens workspace, returns nil if no cached data. Returns new photos that need processing. + func openWorkspace(folder: URL) -> WorkspaceResult? { + guard let db = WorkspaceDB(folder: folder) else { return nil } self.workspace = db - guard db.hasCachedData else { return false } + guard db.hasCachedData else { return nil } let savedPhotos = db.loadPhotos() let groupOrder = db.loadGroupOrder() + let savedPaths = Set(savedPhotos.map(\.path)) // Rebuild photos keyed by relative path var photosByPath: [String: Photo] = [:] @@ -386,6 +392,16 @@ final class CullSession { photosByPath[saved.path] = photo } + // Scan for new files on disk not in workspace + var newPhotos: [Photo] = [] + if let importResult = try? PhotoImporter.scanFiles(in: folder, recursive: importRecursive) { + for (relativePath, photo) in importResult { + if !savedPaths.contains(relativePath) { + newPhotos.append(photo) + } + } + } + // Rebuild groups in saved order var photosByGroup: [String: [Photo]] = [:] for saved in savedPhotos { @@ -399,14 +415,19 @@ final class CullSession { rebuiltGroups.append(PhotoGroup(photos: photos)) } - // Add any ungrouped photos (shouldn't happen but safety) + // Add any ungrouped saved photos let groupedPaths = Set(savedPhotos.compactMap { $0.groupID != nil ? $0.path : nil }) let ungrouped = photosByPath.filter { !groupedPaths.contains($0.key) }.map(\.value) if !ungrouped.isEmpty { rebuiltGroups.append(PhotoGroup(photos: ungrouped)) } - guard !rebuiltGroups.isEmpty else { return false } + // Add new photos as their own group(s) temporarily + if !newPhotos.isEmpty { + rebuiltGroups.append(PhotoGroup(photos: newPhotos)) + } + + guard !rebuiltGroups.isEmpty else { return nil } self.groups = rebuiltGroups db.loadSettings(into: self) @@ -417,6 +438,6 @@ final class CullSession { selectedPhotoIndex = min(selectedPhotoIndex, group.photos.count - 1) } - return true + return WorkspaceResult(newPhotos: newPhotos) } } diff --git a/cull/Services/PhotoImporter.swift b/cull/Services/PhotoImporter.swift index 3cb7874..65e814d 100644 --- a/cull/Services/PhotoImporter.swift +++ b/cull/Services/PhotoImporter.swift @@ -128,7 +128,7 @@ struct PhotoImporter { } /// Read all metadata from a single CGImageSource open — date, dimensions, file size, paired metadata - nonisolated private static func readAllMetadata(url: URL, pairedURL: URL?, formatter: DateFormatter) -> PhotoMetadata { + nonisolated static func readAllMetadata(url: URL, pairedURL: URL?, formatter: DateFormatter) -> PhotoMetadata { var meta = PhotoMetadata() // File size from filesystem @@ -169,6 +169,63 @@ struct PhotoImporter { return meta } + /// Quick file scan — returns (relativePath, Photo) pairs without reading metadata. + /// Used by workspace reload to detect new files. + static func scanFiles(in folder: URL, recursive: Bool) throws -> [(String, Photo)] { + let urls: [URL] + if recursive { + let resourceKeys: Set = [.isRegularFileKey, .contentTypeKey] + guard let enumerator = FileManager.default.enumerator( + at: folder, + includingPropertiesForKeys: Array(resourceKeys), + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { + throw ImportError.cannotReadFolder + } + urls = enumerator.compactMap { $0 as? URL } + } else { + urls = try FileManager.default.contentsOfDirectory( + at: folder, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + } + + var filesByBasename: [String: [URL]] = [:] + for fileURL in urls { + let ext = fileURL.pathExtension.lowercased() + guard supportedExtensions.contains(ext) else { continue } + let basename = fileURL.deletingPathExtension().lastPathComponent + filesByBasename[basename, default: []].append(fileURL) + } + + var result: [(String, Photo)] = [] + var processed: Set = [] + + for (_, urls) in filesByBasename { + let rawURLs = urls.filter { isRAWExtension($0.pathExtension) } + let jpegURLs = urls.filter { isJPEGExtension($0.pathExtension) } + + if let rawURL = rawURLs.first, let jpegURL = jpegURLs.first { + let photo = Photo(url: rawURL) + photo.pairedURL = jpegURL + let relativePath = rawURL.relativePath(from: folder) + result.append((relativePath, photo)) + processed.insert(rawURL) + processed.insert(jpegURL) + } + + for url in urls where !processed.contains(url) { + let photo = Photo(url: url) + let relativePath = url.relativePath(from: folder) + result.append((relativePath, photo)) + processed.insert(url) + } + } + + return result + } + static func isRAWExtension(_ ext: String) -> Bool { let raw: Set = ["cr2", "cr3", "arw", "nef", "dng", "raf", "orf", "rw2"] return raw.contains(ext.lowercased()) diff --git a/cull/Views/ContentView.swift b/cull/Views/ContentView.swift index 6b69df5..300cc2b 100644 --- a/cull/Views/ContentView.swift +++ b/cull/Views/ContentView.swift @@ -55,11 +55,48 @@ struct ContentView: View { Task { do { // Try loading from workspace first - if s.openWorkspace(folder: url) { - await MainActor.run { s.importStatus = "Loading from workspace..." } + if let wsResult = s.openWorkspace(folder: url) { let allPhotos = s.allPhotos + let newPhotos = wsResult.newPhotos + + if !newPhotos.isEmpty { + await MainActor.run { s.importStatus = "Analyzing \(newPhotos.count) new photos..." } + // Read metadata for new photos + let formatter = DateFormatter() + formatter.dateFormat = "yyyy:MM:dd HH:mm:ss" + formatter.locale = Locale(identifier: "en_US_POSIX") + let metaInputs: [(Int, URL, URL?)] = newPhotos.enumerated().map { (i, p) in + (i, p.url, p.pairedURL) + } + let results = await withTaskGroup(of: (Int, PhotoImporter.PhotoMetadata).self, returning: [(Int, PhotoImporter.PhotoMetadata)].self) { group in + for (index, photoURL, pairedURL) in metaInputs { + group.addTask { + let meta = PhotoImporter.readAllMetadata(url: photoURL, pairedURL: pairedURL, formatter: formatter) + return (index, meta) + } + } + var collected: [(Int, PhotoImporter.PhotoMetadata)] = [] + for await result in group { collected.append(result) } + return collected + } + for (index, meta) in results { + let photo = newPhotos[index] + photo.captureDate = meta.captureDate + photo.pixelWidth = meta.pixelWidth + photo.pixelHeight = meta.pixelHeight + photo.fileSize = meta.fileSize + photo.pairedPixelWidth = meta.pairedPixelWidth + photo.pairedPixelHeight = meta.pairedPixelHeight + photo.pairedFileSize = meta.pairedFileSize + } - // Still need to load thumbnails and previews + // Analyze new photos + for photo in newPhotos { + await QualityAnalyzer.analyze(photo: photo) + } + } + + // Load thumbnails and previews await MainActor.run { s.importStatus = "Loading thumbnails..." } await c.preloadAllThumbnails(photos: allPhotos) { p in await MainActor.run { @@ -84,6 +121,9 @@ struct ContentView: View { await MainActor.run { s.importProgress = 1.0 s.isImporting = false + if !newPhotos.isEmpty { + s.saveWorkspace() + } } return }