diff --git a/cull/Models/CullSession.swift b/cull/Models/CullSession.swift index 2310900..709bf29 100644 --- a/cull/Models/CullSession.swift +++ b/cull/Models/CullSession.swift @@ -11,6 +11,7 @@ final class CullSession { /// Zoom state: nil = fit, -1 = center zoom, 0+ = face index var zoomFaceIndex: Int? = nil + var importRecursive: Bool = true var isImporting: Bool = false var importProgress: Double = 0 var importStatus: String = "" diff --git a/cull/Services/PhotoExporter.swift b/cull/Services/PhotoExporter.swift index c20fd57..1c03947 100644 --- a/cull/Services/PhotoExporter.swift +++ b/cull/Services/PhotoExporter.swift @@ -15,6 +15,15 @@ enum ExportMode: String, CaseIterable, Identifiable { var id: String { rawValue } } +enum ExportFolderStructure: String, CaseIterable, Identifiable { + case flat = "Flat" + case separateRawJpeg = "RAW / JPEG folders" + case byRating = "By star rating" + case ratingAndType = "By rating, RAW / JPEG" + + var id: String { rawValue } +} + struct ExportResult { let exported: Int let skipped: Int @@ -27,7 +36,8 @@ struct PhotoExporter { photos: [Photo], destination: URL, fileType: ExportFileType, - mode: ExportMode + mode: ExportMode, + folderStructure: ExportFolderStructure = .flat ) async -> ExportResult { let fm = FileManager.default @@ -49,11 +59,22 @@ struct PhotoExporter { continue } + var photoExported = false for sourceURL in urls { let accessing = sourceURL.startAccessingSecurityScopedResource() defer { if accessing { sourceURL.stopAccessingSecurityScopedResource() } } - let destURL = destination.appendingPathComponent(sourceURL.lastPathComponent) + let subfolder = subfolder(for: sourceURL, photo: photo, structure: folderStructure) + let destDir = subfolder.isEmpty ? destination : destination.appendingPathComponent(subfolder) + + do { + try fm.createDirectory(at: destDir, withIntermediateDirectories: true) + } catch { + errors.append("\(sourceURL.lastPathComponent): Cannot create folder \(subfolder)") + continue + } + + let destURL = destDir.appendingPathComponent(sourceURL.lastPathComponent) do { if fm.fileExists(atPath: destURL.path) { try fm.removeItem(at: destURL) @@ -64,16 +85,34 @@ struct PhotoExporter { case .move: try fm.moveItem(at: sourceURL, to: destURL) } - exported += 1 + photoExported = true } catch { errors.append("\(sourceURL.lastPathComponent): \(error.localizedDescription)") } } + if photoExported { exported += 1 } } return ExportResult(exported: exported, skipped: skipped, errors: errors) } + private static func subfolder(for sourceURL: URL, photo: Photo, structure: ExportFolderStructure) -> String { + let isRAW = PhotoImporter.isRAWExtension(sourceURL.pathExtension) + let typeName = isRAW ? "RAW" : "JPEG" + let ratingName = photo.rating > 0 ? "\(photo.rating)-star" : "Unrated" + + switch structure { + case .flat: + return "" + case .separateRawJpeg: + return typeName + case .byRating: + return ratingName + case .ratingAndType: + return "\(ratingName)/\(typeName)" + } + } + private static func urlsForExport(photo: Photo, fileType: ExportFileType) -> [URL] { switch fileType { case .both: diff --git a/cull/Services/PhotoImporter.swift b/cull/Services/PhotoImporter.swift index bd562dc..1c3e8ff 100644 --- a/cull/Services/PhotoImporter.swift +++ b/cull/Services/PhotoImporter.swift @@ -20,20 +20,29 @@ struct PhotoImporter { return f }() - static func importFolder(_ url: URL) async throws -> ImportResult { - let resourceKeys: Set = [.isRegularFileKey, .contentTypeKey] - guard let enumerator = FileManager.default.enumerator( - at: url, - includingPropertiesForKeys: Array(resourceKeys), - options: [.skipsHiddenFiles, .skipsPackageDescendants] - ) else { - throw ImportError.cannotReadFolder - } - - var filesByBasename: [String: [URL]] = [:] + static func importFolder(_ url: URL, recursive: Bool = true) async throws -> ImportResult { var allURLs: [URL] = [] + var filesByBasename: [String: [URL]] = [:] - let urls: [URL] = enumerator.compactMap { $0 as? URL } + let urls: [URL] + if recursive { + let resourceKeys: Set = [.isRegularFileKey, .contentTypeKey] + guard let enumerator = FileManager.default.enumerator( + at: url, + includingPropertiesForKeys: Array(resourceKeys), + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { + throw ImportError.cannotReadFolder + } + urls = enumerator.compactMap { $0 as? URL } + } else { + let contents = try FileManager.default.contentsOfDirectory( + at: url, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + urls = contents + } for fileURL in urls { let ext = fileURL.pathExtension.lowercased() guard supportedExtensions.contains(ext) else { continue } diff --git a/cull/Views/ContentView.swift b/cull/Views/ContentView.swift index 4eb293b..be68c4d 100644 --- a/cull/Views/ContentView.swift +++ b/cull/Views/ContentView.swift @@ -55,7 +55,7 @@ struct ContentView: View { Task { do { await MainActor.run { s.importStatus = "Scanning photos..." } - let result = try await PhotoImporter.importFolder(url) + let result = try await PhotoImporter.importFolder(url, recursive: s.importRecursive) // Phase 1: Feature print grouping (0-20%) await MainActor.run { s.importStatus = "Grouping similar shots..." } diff --git a/cull/Views/ExportSheet.swift b/cull/Views/ExportSheet.swift index fd01ca3..d0f7f6f 100644 --- a/cull/Views/ExportSheet.swift +++ b/cull/Views/ExportSheet.swift @@ -6,21 +6,17 @@ struct ExportSheet: View { @State private var fileType: ExportFileType = .both @State private var exportMode: ExportMode = .copy - @State private var minimumRating: Int = 1 - @State private var pickedOnly: Bool = false + @State private var folderStructure: ExportFolderStructure = .flat @State private var destination: URL? @State private var isExporting: Bool = false @State private var result: ExportResult? - - @State private var excludeRejects: Bool = true + @State private var useCurrentFilters: 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 minimumRating > 0 && photo.rating < minimumRating { return false } - return true + if useCurrentFilters { + return session.allPhotos.filter { !session.isPhotoFiltered($0) } } + return session.allPhotos } var body: some View { @@ -29,6 +25,8 @@ struct ExportSheet: View { .font(.title2.bold()) Form { + Toggle("Export only visible photos", isOn: $useCurrentFilters) + Picker("File Type", selection: $fileType) { ForEach(ExportFileType.allCases) { type in Text(type.rawValue).tag(type) @@ -41,17 +39,12 @@ struct ExportSheet: View { } } - Picker("Minimum Rating", selection: $minimumRating) { - Text("Any rating").tag(0) - ForEach(1...5, id: \.self) { rating in - Text(String(repeating: "★", count: rating) + String(repeating: "☆", count: 5 - rating)) - .tag(rating) + Picker("Folder Structure", selection: $folderStructure) { + ForEach(ExportFolderStructure.allCases) { structure in + Text(structure.rawValue).tag(structure) } } - Toggle("Picked only", isOn: $pickedOnly) - Toggle("Exclude rejected", isOn: $excludeRejects) - HStack { if let destination { Text(destination.lastPathComponent) @@ -72,7 +65,7 @@ struct ExportSheet: View { if let result { VStack(spacing: 4) { - Text("Exported \(result.exported) files") + Text("Exported \(result.exported) photos") .foregroundStyle(.green) if !result.errors.isEmpty { Text("\(result.errors.count) errors") @@ -95,9 +88,11 @@ struct ExportSheet: View { Button("Cancel") { dismiss() } .keyboardShortcut(.cancelAction) + let canExport = destination != nil && !isExporting && !eligiblePhotos.isEmpty Button("Export") { runExport() } .buttonStyle(.borderedProminent) - .disabled(destination == nil || isExporting || eligiblePhotos.isEmpty) + .disabled(!canExport) + .opacity(canExport ? 1.0 : 0.4) .keyboardShortcut(.defaultAction) } } @@ -136,7 +131,8 @@ struct ExportSheet: View { photos: photos, destination: destination, fileType: fileType, - mode: exportMode + mode: exportMode, + folderStructure: folderStructure ) await MainActor.run { result = exportResult diff --git a/cull/Views/GroupListView.swift b/cull/Views/GroupListView.swift index eaa7b97..61c3b91 100644 --- a/cull/Views/GroupListView.swift +++ b/cull/Views/GroupListView.swift @@ -13,7 +13,8 @@ struct GroupListView: View { GroupThumbnail( group: group, index: index, - isSelected: index == session.selectedGroupIndex + isSelected: index == session.selectedGroupIndex, + visibleCount: group.photos.filter { !session.isPhotoFiltered($0) }.count ) .id(group.id) .onTapGesture { @@ -39,6 +40,7 @@ private struct GroupThumbnail: View { let group: PhotoGroup let index: Int let isSelected: Bool + let visibleCount: Int @Environment(ThumbnailCache.self) private var cache @State private var thumbnail: NSImage? @@ -56,7 +58,7 @@ private struct GroupThumbnail: View { .frame(width: 112, height: 80) } - Text("\(group.photos.count)") + Text("\(visibleCount)") .font(.caption2.bold()) .padding(.horizontal, 5) .padding(.vertical, 2) diff --git a/cull/Views/ImportView.swift b/cull/Views/ImportView.swift index 2208891..345c97c 100644 --- a/cull/Views/ImportView.swift +++ b/cull/Views/ImportView.swift @@ -2,6 +2,7 @@ import SwiftUI import UniformTypeIdentifiers struct ImportView: View { + @Environment(CullSession.self) private var session @State private var isDragging = false var body: some View { @@ -27,6 +28,10 @@ struct ImportView: View { .controlSize(.large) .keyboardShortcut("o") + @Bindable var s = session + Toggle("Include subfolders", isOn: $s.importRecursive) + .toggleStyle(.checkbox) + Text("or drag a folder here") .font(.caption) .foregroundStyle(.tertiary)