From ea616d716a46b04195da60ad110afe9d0a2082df Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Mon, 23 Mar 2026 22:07:11 -0700 Subject: [PATCH] feat: fix export and hide groups --- cull/Services/PhotoExporter.swift | 55 +++++++++++++++------------ cull/Services/PhotoImporter.swift | 4 +- cull/Views/ExportSheet.swift | 58 ++++++++++++++++++----------- cull/Views/GroupListView.swift | 18 +++++---- cull/cull.xcodeproj/project.pbxproj | 29 +++++++++++++-- 5 files changed, 104 insertions(+), 60 deletions(-) diff --git a/cull/Services/PhotoExporter.swift b/cull/Services/PhotoExporter.swift index d093236..c20fd57 100644 --- a/cull/Services/PhotoExporter.swift +++ b/cull/Services/PhotoExporter.swift @@ -15,14 +15,6 @@ enum ExportMode: String, CaseIterable, Identifiable { var id: String { rawValue } } -struct ExportOptions { - var destination: URL - var fileType: ExportFileType = .both - var mode: ExportMode = .copy - var minimumRating: Int = 1 // export photos rated >= this - var includePickedOnly: Bool = false -} - struct ExportResult { let exported: Int let skipped: Int @@ -30,30 +22,43 @@ struct ExportResult { } struct PhotoExporter { - static func export(photos: [Photo], options: ExportOptions) async throws -> ExportResult { + /// Export pre-filtered photos to destination + static func export( + photos: [Photo], + destination: URL, + fileType: ExportFileType, + mode: ExportMode + ) async -> ExportResult { let fm = FileManager.default - try fm.createDirectory(at: options.destination, withIntermediateDirectories: true) + + do { + try fm.createDirectory(at: destination, withIntermediateDirectories: true) + } catch { + return ExportResult(exported: 0, skipped: 0, errors: ["Cannot create destination: \(error.localizedDescription)"]) + } var exported = 0 var skipped = 0 var errors: [String] = [] - let eligible = photos.filter { photo in - if photo.flag == .reject { return false } - if options.includePickedOnly { return photo.flag == .pick } - return photo.rating >= options.minimumRating - } + for photo in photos { + let urls = urlsForExport(photo: photo, fileType: fileType) + + if urls.isEmpty { + skipped += 1 + continue + } - for photo in eligible { - let urlsToExport = urlsForExport(photo: photo, fileType: options.fileType) + for sourceURL in urls { + let accessing = sourceURL.startAccessingSecurityScopedResource() + defer { if accessing { sourceURL.stopAccessingSecurityScopedResource() } } - for sourceURL in urlsToExport { - let destURL = options.destination.appendingPathComponent(sourceURL.lastPathComponent) + let destURL = destination.appendingPathComponent(sourceURL.lastPathComponent) do { if fm.fileExists(atPath: destURL.path) { try fm.removeItem(at: destURL) } - switch options.mode { + switch mode { case .copy: try fm.copyItem(at: sourceURL, to: destURL) case .move: @@ -64,8 +69,6 @@ struct PhotoExporter { errors.append("\(sourceURL.lastPathComponent): \(error.localizedDescription)") } } - - skipped += urlsToExport.isEmpty ? 1 : 0 } return ExportResult(exported: exported, skipped: skipped, errors: errors) @@ -78,9 +81,13 @@ struct PhotoExporter { if let paired = photo.pairedURL { urls.append(paired) } return urls case .raw: - return photo.isRAW ? [photo.url] : (photo.pairedURL.map { [$0] } ?? []) + if photo.isRAW { return [photo.url] } + if let paired = photo.pairedURL, PhotoImporter.isRAWExtension(paired.pathExtension) { return [paired] } + return [] case .jpeg: - return photo.isJPEG ? [photo.url] : (photo.pairedURL.map { [$0] } ?? []) + if photo.isJPEG { return [photo.url] } + if let paired = photo.pairedURL, PhotoImporter.isJPEGExtension(paired.pathExtension) { return [paired] } + return [] } } } diff --git a/cull/Services/PhotoImporter.swift b/cull/Services/PhotoImporter.swift index 0023013..d638c85 100644 --- a/cull/Services/PhotoImporter.swift +++ b/cull/Services/PhotoImporter.swift @@ -84,12 +84,12 @@ struct PhotoImporter { return formatter.date(from: dateString) } - private static func isRAWExtension(_ ext: String) -> Bool { + static func isRAWExtension(_ ext: String) -> Bool { let raw: Set = ["cr2", "cr3", "arw", "nef", "dng", "raf", "orf", "rw2"] return raw.contains(ext.lowercased()) } - private static func isJPEGExtension(_ ext: String) -> Bool { + static func isJPEGExtension(_ ext: String) -> Bool { let jpeg: Set = ["jpg", "jpeg"] return jpeg.contains(ext.lowercased()) } diff --git a/cull/Views/ExportSheet.swift b/cull/Views/ExportSheet.swift index 6e005b8..fd01ca3 100644 --- a/cull/Views/ExportSheet.swift +++ b/cull/Views/ExportSheet.swift @@ -12,12 +12,15 @@ struct ExportSheet: View { @State private var isExporting: Bool = false @State private var result: ExportResult? - private var eligibleCount: Int { + @State private var excludeRejects: Bool = true + + private var eligiblePhotos: [Photo] { session.allPhotos.filter { photo in + if excludeRejects && photo.flag == .reject { return false } if pickedOnly && photo.flag != .pick { return false } - if photo.flag == .reject { return false } - return photo.rating >= minimumRating - }.count + if minimumRating > 0 && photo.rating < minimumRating { return false } + return true + } } var body: some View { @@ -39,19 +42,15 @@ struct ExportSheet: View { } Picker("Minimum Rating", selection: $minimumRating) { - Text("All (unrated included)").tag(0) + Text("Any rating").tag(0) ForEach(1...5, id: \.self) { rating in - HStack(spacing: 1) { - ForEach(1...rating, id: \.self) { _ in - Image(systemName: "star.fill") - .font(.caption2) - } - } - .tag(rating) + Text(String(repeating: "★", count: rating) + String(repeating: "☆", count: 5 - rating)) + .tag(rating) } } Toggle("Picked only", isOn: $pickedOnly) + Toggle("Exclude rejected", isOn: $excludeRejects) HStack { if let destination { @@ -68,7 +67,7 @@ struct ExportSheet: View { } .formStyle(.grouped) - Text("\(eligibleCount) photos will be \(exportMode == .move ? "moved" : "copied")") + Text("\(eligiblePhotos.count) photos will be \(exportMode == .move ? "moved" : "copied")") .foregroundStyle(.secondary) if let result { @@ -78,6 +77,16 @@ struct ExportSheet: View { if !result.errors.isEmpty { Text("\(result.errors.count) errors") .foregroundStyle(.red) + ScrollView { + VStack(alignment: .leading, spacing: 2) { + ForEach(result.errors.prefix(10), id: \.self) { error in + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + } + .frame(maxHeight: 80) } } } @@ -88,7 +97,7 @@ struct ExportSheet: View { Button("Export") { runExport() } .buttonStyle(.borderedProminent) - .disabled(destination == nil || isExporting || eligibleCount == 0) + .disabled(destination == nil || isExporting || eligiblePhotos.isEmpty) .keyboardShortcut(.defaultAction) } } @@ -111,18 +120,23 @@ struct ExportSheet: View { private func runExport() { guard let destination else { return } isExporting = true + let photos = eligiblePhotos + let sourceFolder = session.sourceFolder Task { - let options = ExportOptions( + // Access security-scoped resources + let destAccess = destination.startAccessingSecurityScopedResource() + let srcAccess = sourceFolder?.startAccessingSecurityScopedResource() ?? false + defer { + if destAccess { destination.stopAccessingSecurityScopedResource() } + if srcAccess { sourceFolder?.stopAccessingSecurityScopedResource() } + } + + let exportResult = await PhotoExporter.export( + photos: photos, destination: destination, fileType: fileType, - mode: exportMode, - minimumRating: minimumRating, - includePickedOnly: pickedOnly - ) - let exportResult = try? await PhotoExporter.export( - photos: session.allPhotos, - options: options + mode: exportMode ) await MainActor.run { result = exportResult diff --git a/cull/Views/GroupListView.swift b/cull/Views/GroupListView.swift index 1e1d7ac..eaa7b97 100644 --- a/cull/Views/GroupListView.swift +++ b/cull/Views/GroupListView.swift @@ -9,14 +9,16 @@ struct GroupListView: View { ScrollView { LazyVStack(spacing: 2) { ForEach(Array(session.groups.enumerated()), id: \.element.id) { index, group in - GroupThumbnail( - group: group, - index: index, - isSelected: index == session.selectedGroupIndex - ) - .id(group.id) - .onTapGesture { - session.selectGroup(at: index) + if group.photos.contains(where: { !session.isPhotoFiltered($0) }) { + GroupThumbnail( + group: group, + index: index, + isSelected: index == session.selectedGroupIndex + ) + .id(group.id) + .onTapGesture { + session.selectGroup(at: index) + } } } } diff --git a/cull/cull.xcodeproj/project.pbxproj b/cull/cull.xcodeproj/project.pbxproj index 910fb02..e1d2306 100644 --- a/cull/cull.xcodeproj/project.pbxproj +++ b/cull/cull.xcodeproj/project.pbxproj @@ -26,7 +26,6 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 0B0EC26A2F722109004523FA /* cull.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; name = cull.app; path = /Users/kierank/code/personal/cull/cull/build/Debug/cull.app; sourceTree = ""; }; 0B0EC2712F72210B004523FA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 0B0EC2782F722491004523FA /* CullApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CullApp.swift; sourceTree = ""; }; 0B0EC2792F722491004523FA /* CullSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CullSession.swift; sourceTree = ""; }; @@ -43,6 +42,7 @@ 0B0EC2862F722491004523FA /* GroupListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupListView.swift; sourceTree = ""; }; 0B0EC2872F722491004523FA /* ImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportView.swift; sourceTree = ""; }; 0B0EC2882F722491004523FA /* PhotoViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoViewer.swift; sourceTree = ""; }; + 0B0EC2992F724FE5004523FA /* cull.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cull.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -64,6 +64,7 @@ 0B0EC27C2F722491004523FA /* Models */, 0B0EC2822F722491004523FA /* Services */, 0B0EC2892F722491004523FA /* Views */, + 0B0EC2992F724FE5004523FA /* cull.app */, ); sourceTree = ""; }; @@ -121,7 +122,7 @@ packageProductDependencies = ( ); productName = cull; - productReference = 0B0EC26A2F722109004523FA /* cull.app */; + productReference = 0B0EC2992F724FE5004523FA /* cull.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -326,8 +327,18 @@ DEVELOPMENT_TEAM = M67B42LX8D; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readonly; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( @@ -358,8 +369,18 @@ DEVELOPMENT_TEAM = M67B42LX8D; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readonly; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( -- 2.51.2