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: fix export and hide groups

+104 -60
+31 -24
cull/Services/PhotoExporter.swift
··· 15 15 var id: String { rawValue } 16 16 } 17 17 18 - struct ExportOptions { 19 - var destination: URL 20 - var fileType: ExportFileType = .both 21 - var mode: ExportMode = .copy 22 - var minimumRating: Int = 1 // export photos rated >= this 23 - var includePickedOnly: Bool = false 24 - } 25 - 26 18 struct ExportResult { 27 19 let exported: Int 28 20 let skipped: Int ··· 30 22 } 31 23 32 24 struct PhotoExporter { 33 - static func export(photos: [Photo], options: ExportOptions) async throws -> ExportResult { 25 + /// Export pre-filtered photos to destination 26 + static func export( 27 + photos: [Photo], 28 + destination: URL, 29 + fileType: ExportFileType, 30 + mode: ExportMode 31 + ) async -> ExportResult { 34 32 let fm = FileManager.default 35 - try fm.createDirectory(at: options.destination, withIntermediateDirectories: true) 33 + 34 + do { 35 + try fm.createDirectory(at: destination, withIntermediateDirectories: true) 36 + } catch { 37 + return ExportResult(exported: 0, skipped: 0, errors: ["Cannot create destination: \(error.localizedDescription)"]) 38 + } 36 39 37 40 var exported = 0 38 41 var skipped = 0 39 42 var errors: [String] = [] 40 43 41 - let eligible = photos.filter { photo in 42 - if photo.flag == .reject { return false } 43 - if options.includePickedOnly { return photo.flag == .pick } 44 - return photo.rating >= options.minimumRating 45 - } 44 + for photo in photos { 45 + let urls = urlsForExport(photo: photo, fileType: fileType) 46 + 47 + if urls.isEmpty { 48 + skipped += 1 49 + continue 50 + } 46 51 47 - for photo in eligible { 48 - let urlsToExport = urlsForExport(photo: photo, fileType: options.fileType) 52 + for sourceURL in urls { 53 + let accessing = sourceURL.startAccessingSecurityScopedResource() 54 + defer { if accessing { sourceURL.stopAccessingSecurityScopedResource() } } 49 55 50 - for sourceURL in urlsToExport { 51 - let destURL = options.destination.appendingPathComponent(sourceURL.lastPathComponent) 56 + let destURL = destination.appendingPathComponent(sourceURL.lastPathComponent) 52 57 do { 53 58 if fm.fileExists(atPath: destURL.path) { 54 59 try fm.removeItem(at: destURL) 55 60 } 56 - switch options.mode { 61 + switch mode { 57 62 case .copy: 58 63 try fm.copyItem(at: sourceURL, to: destURL) 59 64 case .move: ··· 64 69 errors.append("\(sourceURL.lastPathComponent): \(error.localizedDescription)") 65 70 } 66 71 } 67 - 68 - skipped += urlsToExport.isEmpty ? 1 : 0 69 72 } 70 73 71 74 return ExportResult(exported: exported, skipped: skipped, errors: errors) ··· 78 81 if let paired = photo.pairedURL { urls.append(paired) } 79 82 return urls 80 83 case .raw: 81 - return photo.isRAW ? [photo.url] : (photo.pairedURL.map { [$0] } ?? []) 84 + if photo.isRAW { return [photo.url] } 85 + if let paired = photo.pairedURL, PhotoImporter.isRAWExtension(paired.pathExtension) { return [paired] } 86 + return [] 82 87 case .jpeg: 83 - return photo.isJPEG ? [photo.url] : (photo.pairedURL.map { [$0] } ?? []) 88 + if photo.isJPEG { return [photo.url] } 89 + if let paired = photo.pairedURL, PhotoImporter.isJPEGExtension(paired.pathExtension) { return [paired] } 90 + return [] 84 91 } 85 92 } 86 93 }
+2 -2
cull/Services/PhotoImporter.swift
··· 84 84 return formatter.date(from: dateString) 85 85 } 86 86 87 - private static func isRAWExtension(_ ext: String) -> Bool { 87 + static func isRAWExtension(_ ext: String) -> Bool { 88 88 let raw: Set<String> = ["cr2", "cr3", "arw", "nef", "dng", "raf", "orf", "rw2"] 89 89 return raw.contains(ext.lowercased()) 90 90 } 91 91 92 - private static func isJPEGExtension(_ ext: String) -> Bool { 92 + static func isJPEGExtension(_ ext: String) -> Bool { 93 93 let jpeg: Set<String> = ["jpg", "jpeg"] 94 94 return jpeg.contains(ext.lowercased()) 95 95 }
+36 -22
cull/Views/ExportSheet.swift
··· 12 12 @State private var isExporting: Bool = false 13 13 @State private var result: ExportResult? 14 14 15 - private var eligibleCount: Int { 15 + @State private var excludeRejects: Bool = true 16 + 17 + private var eligiblePhotos: [Photo] { 16 18 session.allPhotos.filter { photo in 19 + if excludeRejects && photo.flag == .reject { return false } 17 20 if pickedOnly && photo.flag != .pick { return false } 18 - if photo.flag == .reject { return false } 19 - return photo.rating >= minimumRating 20 - }.count 21 + if minimumRating > 0 && photo.rating < minimumRating { return false } 22 + return true 23 + } 21 24 } 22 25 23 26 var body: some View { ··· 39 42 } 40 43 41 44 Picker("Minimum Rating", selection: $minimumRating) { 42 - Text("All (unrated included)").tag(0) 45 + Text("Any rating").tag(0) 43 46 ForEach(1...5, id: \.self) { rating in 44 - HStack(spacing: 1) { 45 - ForEach(1...rating, id: \.self) { _ in 46 - Image(systemName: "star.fill") 47 - .font(.caption2) 48 - } 49 - } 50 - .tag(rating) 47 + Text(String(repeating: "★", count: rating) + String(repeating: "☆", count: 5 - rating)) 48 + .tag(rating) 51 49 } 52 50 } 53 51 54 52 Toggle("Picked only", isOn: $pickedOnly) 53 + Toggle("Exclude rejected", isOn: $excludeRejects) 55 54 56 55 HStack { 57 56 if let destination { ··· 68 67 } 69 68 .formStyle(.grouped) 70 69 71 - Text("\(eligibleCount) photos will be \(exportMode == .move ? "moved" : "copied")") 70 + Text("\(eligiblePhotos.count) photos will be \(exportMode == .move ? "moved" : "copied")") 72 71 .foregroundStyle(.secondary) 73 72 74 73 if let result { ··· 78 77 if !result.errors.isEmpty { 79 78 Text("\(result.errors.count) errors") 80 79 .foregroundStyle(.red) 80 + ScrollView { 81 + VStack(alignment: .leading, spacing: 2) { 82 + ForEach(result.errors.prefix(10), id: \.self) { error in 83 + Text(error) 84 + .font(.caption) 85 + .foregroundStyle(.red) 86 + } 87 + } 88 + } 89 + .frame(maxHeight: 80) 81 90 } 82 91 } 83 92 } ··· 88 97 89 98 Button("Export") { runExport() } 90 99 .buttonStyle(.borderedProminent) 91 - .disabled(destination == nil || isExporting || eligibleCount == 0) 100 + .disabled(destination == nil || isExporting || eligiblePhotos.isEmpty) 92 101 .keyboardShortcut(.defaultAction) 93 102 } 94 103 } ··· 111 120 private func runExport() { 112 121 guard let destination else { return } 113 122 isExporting = true 123 + let photos = eligiblePhotos 124 + let sourceFolder = session.sourceFolder 114 125 115 126 Task { 116 - let options = ExportOptions( 127 + // Access security-scoped resources 128 + let destAccess = destination.startAccessingSecurityScopedResource() 129 + let srcAccess = sourceFolder?.startAccessingSecurityScopedResource() ?? false 130 + defer { 131 + if destAccess { destination.stopAccessingSecurityScopedResource() } 132 + if srcAccess { sourceFolder?.stopAccessingSecurityScopedResource() } 133 + } 134 + 135 + let exportResult = await PhotoExporter.export( 136 + photos: photos, 117 137 destination: destination, 118 138 fileType: fileType, 119 - mode: exportMode, 120 - minimumRating: minimumRating, 121 - includePickedOnly: pickedOnly 122 - ) 123 - let exportResult = try? await PhotoExporter.export( 124 - photos: session.allPhotos, 125 - options: options 139 + mode: exportMode 126 140 ) 127 141 await MainActor.run { 128 142 result = exportResult
+10 -8
cull/Views/GroupListView.swift
··· 9 9 ScrollView { 10 10 LazyVStack(spacing: 2) { 11 11 ForEach(Array(session.groups.enumerated()), id: \.element.id) { index, group in 12 - GroupThumbnail( 13 - group: group, 14 - index: index, 15 - isSelected: index == session.selectedGroupIndex 16 - ) 17 - .id(group.id) 18 - .onTapGesture { 19 - session.selectGroup(at: index) 12 + if group.photos.contains(where: { !session.isPhotoFiltered($0) }) { 13 + GroupThumbnail( 14 + group: group, 15 + index: index, 16 + isSelected: index == session.selectedGroupIndex 17 + ) 18 + .id(group.id) 19 + .onTapGesture { 20 + session.selectGroup(at: index) 21 + } 20 22 } 21 23 } 22 24 }
+25 -4
cull/cull.xcodeproj/project.pbxproj
··· 26 26 /* End PBXBuildFile section */ 27 27 28 28 /* Begin PBXFileReference section */ 29 - 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 = "<absolute>"; }; 30 29 0B0EC2712F72210B004523FA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 31 30 0B0EC2782F722491004523FA /* CullApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CullApp.swift; sourceTree = "<group>"; }; 32 31 0B0EC2792F722491004523FA /* CullSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CullSession.swift; sourceTree = "<group>"; }; ··· 43 42 0B0EC2862F722491004523FA /* GroupListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupListView.swift; sourceTree = "<group>"; }; 44 43 0B0EC2872F722491004523FA /* ImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportView.swift; sourceTree = "<group>"; }; 45 44 0B0EC2882F722491004523FA /* PhotoViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoViewer.swift; sourceTree = "<group>"; }; 45 + 0B0EC2992F724FE5004523FA /* cull.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cull.app; sourceTree = BUILT_PRODUCTS_DIR; }; 46 46 /* End PBXFileReference section */ 47 47 48 48 /* Begin PBXFrameworksBuildPhase section */ ··· 64 64 0B0EC27C2F722491004523FA /* Models */, 65 65 0B0EC2822F722491004523FA /* Services */, 66 66 0B0EC2892F722491004523FA /* Views */, 67 + 0B0EC2992F724FE5004523FA /* cull.app */, 67 68 ); 68 69 sourceTree = "<group>"; 69 70 }; ··· 121 122 packageProductDependencies = ( 122 123 ); 123 124 productName = cull; 124 - productReference = 0B0EC26A2F722109004523FA /* cull.app */; 125 + productReference = 0B0EC2992F724FE5004523FA /* cull.app */; 125 126 productType = "com.apple.product-type.application"; 126 127 }; 127 128 /* End PBXNativeTarget section */ ··· 326 327 DEVELOPMENT_TEAM = M67B42LX8D; 327 328 ENABLE_APP_SANDBOX = YES; 328 329 ENABLE_HARDENED_RUNTIME = YES; 330 + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; 331 + ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; 329 332 ENABLE_PREVIEWS = YES; 330 - ENABLE_USER_SELECTED_FILES = readonly; 333 + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; 334 + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; 335 + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; 336 + ENABLE_RESOURCE_ACCESS_CAMERA = NO; 337 + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; 338 + ENABLE_RESOURCE_ACCESS_LOCATION = NO; 339 + ENABLE_RESOURCE_ACCESS_PRINTING = NO; 340 + ENABLE_RESOURCE_ACCESS_USB = NO; 341 + ENABLE_USER_SELECTED_FILES = readwrite; 331 342 GENERATE_INFOPLIST_FILE = YES; 332 343 INFOPLIST_KEY_NSHumanReadableCopyright = ""; 333 344 LD_RUNPATH_SEARCH_PATHS = ( ··· 358 369 DEVELOPMENT_TEAM = M67B42LX8D; 359 370 ENABLE_APP_SANDBOX = YES; 360 371 ENABLE_HARDENED_RUNTIME = YES; 372 + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; 373 + ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; 361 374 ENABLE_PREVIEWS = YES; 362 - ENABLE_USER_SELECTED_FILES = readonly; 375 + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; 376 + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; 377 + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; 378 + ENABLE_RESOURCE_ACCESS_CAMERA = NO; 379 + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; 380 + ENABLE_RESOURCE_ACCESS_LOCATION = NO; 381 + ENABLE_RESOURCE_ACCESS_PRINTING = NO; 382 + ENABLE_RESOURCE_ACCESS_USB = NO; 383 + ENABLE_USER_SELECTED_FILES = readwrite; 363 384 GENERATE_INFOPLIST_FILE = YES; 364 385 INFOPLIST_KEY_NSHumanReadableCopyright = ""; 365 386 LD_RUNPATH_SEARCH_PATHS = (