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: add more import export options

+90 -38
+1
cull/Models/CullSession.swift
··· 11 11 /// Zoom state: nil = fit, -1 = center zoom, 0+ = face index 12 12 var zoomFaceIndex: Int? = nil 13 13 14 + var importRecursive: Bool = true 14 15 var isImporting: Bool = false 15 16 var importProgress: Double = 0 16 17 var importStatus: String = ""
+42 -3
cull/Services/PhotoExporter.swift
··· 15 15 var id: String { rawValue } 16 16 } 17 17 18 + enum ExportFolderStructure: String, CaseIterable, Identifiable { 19 + case flat = "Flat" 20 + case separateRawJpeg = "RAW / JPEG folders" 21 + case byRating = "By star rating" 22 + case ratingAndType = "By rating, RAW / JPEG" 23 + 24 + var id: String { rawValue } 25 + } 26 + 18 27 struct ExportResult { 19 28 let exported: Int 20 29 let skipped: Int ··· 27 36 photos: [Photo], 28 37 destination: URL, 29 38 fileType: ExportFileType, 30 - mode: ExportMode 39 + mode: ExportMode, 40 + folderStructure: ExportFolderStructure = .flat 31 41 ) async -> ExportResult { 32 42 let fm = FileManager.default 33 43 ··· 49 59 continue 50 60 } 51 61 62 + var photoExported = false 52 63 for sourceURL in urls { 53 64 let accessing = sourceURL.startAccessingSecurityScopedResource() 54 65 defer { if accessing { sourceURL.stopAccessingSecurityScopedResource() } } 55 66 56 - let destURL = destination.appendingPathComponent(sourceURL.lastPathComponent) 67 + let subfolder = subfolder(for: sourceURL, photo: photo, structure: folderStructure) 68 + let destDir = subfolder.isEmpty ? destination : destination.appendingPathComponent(subfolder) 69 + 70 + do { 71 + try fm.createDirectory(at: destDir, withIntermediateDirectories: true) 72 + } catch { 73 + errors.append("\(sourceURL.lastPathComponent): Cannot create folder \(subfolder)") 74 + continue 75 + } 76 + 77 + let destURL = destDir.appendingPathComponent(sourceURL.lastPathComponent) 57 78 do { 58 79 if fm.fileExists(atPath: destURL.path) { 59 80 try fm.removeItem(at: destURL) ··· 64 85 case .move: 65 86 try fm.moveItem(at: sourceURL, to: destURL) 66 87 } 67 - exported += 1 88 + photoExported = true 68 89 } catch { 69 90 errors.append("\(sourceURL.lastPathComponent): \(error.localizedDescription)") 70 91 } 71 92 } 93 + if photoExported { exported += 1 } 72 94 } 73 95 74 96 return ExportResult(exported: exported, skipped: skipped, errors: errors) 97 + } 98 + 99 + private static func subfolder(for sourceURL: URL, photo: Photo, structure: ExportFolderStructure) -> String { 100 + let isRAW = PhotoImporter.isRAWExtension(sourceURL.pathExtension) 101 + let typeName = isRAW ? "RAW" : "JPEG" 102 + let ratingName = photo.rating > 0 ? "\(photo.rating)-star" : "Unrated" 103 + 104 + switch structure { 105 + case .flat: 106 + return "" 107 + case .separateRawJpeg: 108 + return typeName 109 + case .byRating: 110 + return ratingName 111 + case .ratingAndType: 112 + return "\(ratingName)/\(typeName)" 113 + } 75 114 } 76 115 77 116 private static func urlsForExport(photo: Photo, fileType: ExportFileType) -> [URL] {
+21 -12
cull/Services/PhotoImporter.swift
··· 20 20 return f 21 21 }() 22 22 23 - static func importFolder(_ url: URL) async throws -> ImportResult { 24 - let resourceKeys: Set<URLResourceKey> = [.isRegularFileKey, .contentTypeKey] 25 - guard let enumerator = FileManager.default.enumerator( 26 - at: url, 27 - includingPropertiesForKeys: Array(resourceKeys), 28 - options: [.skipsHiddenFiles, .skipsPackageDescendants] 29 - ) else { 30 - throw ImportError.cannotReadFolder 31 - } 32 - 23 + static func importFolder(_ url: URL, recursive: Bool = true) async throws -> ImportResult { 24 + var allURLs: [URL] = [] 33 25 var filesByBasename: [String: [URL]] = [:] 34 - var allURLs: [URL] = [] 35 26 36 - let urls: [URL] = enumerator.compactMap { $0 as? URL } 27 + let urls: [URL] 28 + if recursive { 29 + let resourceKeys: Set<URLResourceKey> = [.isRegularFileKey, .contentTypeKey] 30 + guard let enumerator = FileManager.default.enumerator( 31 + at: url, 32 + includingPropertiesForKeys: Array(resourceKeys), 33 + options: [.skipsHiddenFiles, .skipsPackageDescendants] 34 + ) else { 35 + throw ImportError.cannotReadFolder 36 + } 37 + urls = enumerator.compactMap { $0 as? URL } 38 + } else { 39 + let contents = try FileManager.default.contentsOfDirectory( 40 + at: url, 41 + includingPropertiesForKeys: nil, 42 + options: [.skipsHiddenFiles] 43 + ) 44 + urls = contents 45 + } 37 46 for fileURL in urls { 38 47 let ext = fileURL.pathExtension.lowercased() 39 48 guard supportedExtensions.contains(ext) else { continue }
+1 -1
cull/Views/ContentView.swift
··· 55 55 Task { 56 56 do { 57 57 await MainActor.run { s.importStatus = "Scanning photos..." } 58 - let result = try await PhotoImporter.importFolder(url) 58 + let result = try await PhotoImporter.importFolder(url, recursive: s.importRecursive) 59 59 60 60 // Phase 1: Feature print grouping (0-20%) 61 61 await MainActor.run { s.importStatus = "Grouping similar shots..." }
+16 -20
cull/Views/ExportSheet.swift
··· 6 6 7 7 @State private var fileType: ExportFileType = .both 8 8 @State private var exportMode: ExportMode = .copy 9 - @State private var minimumRating: Int = 1 10 - @State private var pickedOnly: Bool = false 9 + @State private var folderStructure: ExportFolderStructure = .flat 11 10 @State private var destination: URL? 12 11 @State private var isExporting: Bool = false 13 12 @State private var result: ExportResult? 14 - 15 - @State private var excludeRejects: Bool = true 13 + @State private var useCurrentFilters: Bool = true 16 14 17 15 private var eligiblePhotos: [Photo] { 18 - session.allPhotos.filter { photo in 19 - if excludeRejects && photo.flag == .reject { return false } 20 - if pickedOnly && photo.flag != .pick { return false } 21 - if minimumRating > 0 && photo.rating < minimumRating { return false } 22 - return true 16 + if useCurrentFilters { 17 + return session.allPhotos.filter { !session.isPhotoFiltered($0) } 23 18 } 19 + return session.allPhotos 24 20 } 25 21 26 22 var body: some View { ··· 29 25 .font(.title2.bold()) 30 26 31 27 Form { 28 + Toggle("Export only visible photos", isOn: $useCurrentFilters) 29 + 32 30 Picker("File Type", selection: $fileType) { 33 31 ForEach(ExportFileType.allCases) { type in 34 32 Text(type.rawValue).tag(type) ··· 41 39 } 42 40 } 43 41 44 - Picker("Minimum Rating", selection: $minimumRating) { 45 - Text("Any rating").tag(0) 46 - ForEach(1...5, id: \.self) { rating in 47 - Text(String(repeating: "★", count: rating) + String(repeating: "☆", count: 5 - rating)) 48 - .tag(rating) 42 + Picker("Folder Structure", selection: $folderStructure) { 43 + ForEach(ExportFolderStructure.allCases) { structure in 44 + Text(structure.rawValue).tag(structure) 49 45 } 50 46 } 51 - 52 - Toggle("Picked only", isOn: $pickedOnly) 53 - Toggle("Exclude rejected", isOn: $excludeRejects) 54 47 55 48 HStack { 56 49 if let destination { ··· 72 65 73 66 if let result { 74 67 VStack(spacing: 4) { 75 - Text("Exported \(result.exported) files") 68 + Text("Exported \(result.exported) photos") 76 69 .foregroundStyle(.green) 77 70 if !result.errors.isEmpty { 78 71 Text("\(result.errors.count) errors") ··· 95 88 Button("Cancel") { dismiss() } 96 89 .keyboardShortcut(.cancelAction) 97 90 91 + let canExport = destination != nil && !isExporting && !eligiblePhotos.isEmpty 98 92 Button("Export") { runExport() } 99 93 .buttonStyle(.borderedProminent) 100 - .disabled(destination == nil || isExporting || eligiblePhotos.isEmpty) 94 + .disabled(!canExport) 95 + .opacity(canExport ? 1.0 : 0.4) 101 96 .keyboardShortcut(.defaultAction) 102 97 } 103 98 } ··· 136 131 photos: photos, 137 132 destination: destination, 138 133 fileType: fileType, 139 - mode: exportMode 134 + mode: exportMode, 135 + folderStructure: folderStructure 140 136 ) 141 137 await MainActor.run { 142 138 result = exportResult
+4 -2
cull/Views/GroupListView.swift
··· 13 13 GroupThumbnail( 14 14 group: group, 15 15 index: index, 16 - isSelected: index == session.selectedGroupIndex 16 + isSelected: index == session.selectedGroupIndex, 17 + visibleCount: group.photos.filter { !session.isPhotoFiltered($0) }.count 17 18 ) 18 19 .id(group.id) 19 20 .onTapGesture { ··· 39 40 let group: PhotoGroup 40 41 let index: Int 41 42 let isSelected: Bool 43 + let visibleCount: Int 42 44 @Environment(ThumbnailCache.self) private var cache 43 45 @State private var thumbnail: NSImage? 44 46 ··· 56 58 .frame(width: 112, height: 80) 57 59 } 58 60 59 - Text("\(group.photos.count)") 61 + Text("\(visibleCount)") 60 62 .font(.caption2.bold()) 61 63 .padding(.horizontal, 5) 62 64 .padding(.vertical, 2)
+5
cull/Views/ImportView.swift
··· 2 2 import UniformTypeIdentifiers 3 3 4 4 struct ImportView: View { 5 + @Environment(CullSession.self) private var session 5 6 @State private var isDragging = false 6 7 7 8 var body: some View { ··· 26 27 .buttonStyle(.borderedProminent) 27 28 .controlSize(.large) 28 29 .keyboardShortcut("o") 30 + 31 + @Bindable var s = session 32 + Toggle("Include subfolders", isOn: $s.importRecursive) 33 + .toggleStyle(.checkbox) 29 34 30 35 Text("or drag a folder here") 31 36 .font(.caption)