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 workspaces

+523 -8
+104
cull/Models/CullSession.swift
··· 17 17 var importStatus: String = "" 18 18 19 19 var undoManager: UndoManager? 20 + var workspace: WorkspaceDB? 21 + private var saveTask: Task<Void, Never>? 20 22 21 23 // Remember cursor position per group 22 24 private var groupCursorPositions: [UUID: Int] = [:] 23 25 24 26 // Filters: command-click to toggle hiding photos with these attributes 25 27 var hiddenRatings: Set<Int> = [] // ratings to hide (1-5) 28 + var hideUnrated: Bool = false 26 29 var hidePicks: Bool = false 27 30 var hideRejects: Bool = false 28 31 ··· 33 36 hiddenRatings.insert(rating) 34 37 } 35 38 ensureVisibleSelection() 39 + scheduleSave() 36 40 } 37 41 38 42 func togglePickFilter() { 39 43 hidePicks.toggle() 40 44 ensureVisibleSelection() 45 + scheduleSave() 41 46 } 42 47 43 48 func toggleRejectFilter() { 44 49 hideRejects.toggle() 45 50 ensureVisibleSelection() 51 + scheduleSave() 52 + } 53 + 54 + func toggleUnratedFilter() { 55 + hideUnrated.toggle() 56 + ensureVisibleSelection() 57 + scheduleSave() 46 58 } 47 59 48 60 func isPhotoFiltered(_ photo: Photo) -> Bool { 49 61 if hidePicks && photo.flag == .pick { return true } 50 62 if hideRejects && photo.flag == .reject { return true } 63 + if hideUnrated && photo.rating == 0 && photo.flag == .none { return true } 51 64 if photo.rating > 0 && hiddenRatings.contains(photo.rating) { return true } 52 65 return false 53 66 } ··· 258 271 session.applyPhotoState(photo, rating: oldRating, flag: oldFlag, actionName: actionName) 259 272 } 260 273 undoManager?.setActionName(actionName) 274 + scheduleSave() 261 275 } 262 276 263 277 func setRating(_ rating: Int) { ··· 314 328 315 329 private func resetZoom() { 316 330 zoomFaceIndex = nil 331 + } 332 + 333 + // MARK: - Workspace persistence 334 + 335 + /// Debounced auto-save — coalesces rapid changes into a single write 336 + func scheduleSave() { 337 + saveTask?.cancel() 338 + saveTask = Task { @MainActor [weak self] in 339 + try? await Task.sleep(for: .milliseconds(500)) 340 + guard !Task.isCancelled else { return } 341 + self?.saveWorkspace() 342 + } 343 + } 344 + 345 + func saveWorkspace() { 346 + guard let workspace, let sourceFolder else { return } 347 + let allPhotos = groups.flatMap(\.photos) 348 + workspace.savePhotos(allPhotos, sourceFolder: sourceFolder) 349 + workspace.saveGroups(groups, sourceFolder: sourceFolder) 350 + workspace.saveSettings(session: self) 351 + } 352 + 353 + func openWorkspace(folder: URL) -> Bool { 354 + guard let db = WorkspaceDB(folder: folder) else { return false } 355 + self.workspace = db 356 + 357 + guard db.hasCachedData else { return false } 358 + 359 + let savedPhotos = db.loadPhotos() 360 + let groupOrder = db.loadGroupOrder() 361 + 362 + // Rebuild photos keyed by relative path 363 + var photosByPath: [String: Photo] = [:] 364 + for saved in savedPhotos { 365 + let url = folder.appendingPathComponent(saved.path) 366 + guard FileManager.default.fileExists(atPath: url.path) else { continue } 367 + let photo = Photo(url: url) 368 + if let pairedPath = saved.pairedPath { 369 + let pairedURL = folder.appendingPathComponent(pairedPath) 370 + if FileManager.default.fileExists(atPath: pairedURL.path) { 371 + photo.pairedURL = pairedURL 372 + } 373 + } 374 + photo.rating = saved.rating 375 + photo.flag = saved.flag 376 + photo.blurScore = saved.blurScore 377 + photo.faceSharpness = saved.faceSharpness 378 + photo.faceRegions = saved.faceRegions 379 + photo.pixelWidth = saved.pixelWidth 380 + photo.pixelHeight = saved.pixelHeight 381 + photo.fileSize = saved.fileSize 382 + photo.pairedPixelWidth = saved.pairedPixelWidth 383 + photo.pairedPixelHeight = saved.pairedPixelHeight 384 + photo.pairedFileSize = saved.pairedFileSize 385 + photo.captureDate = saved.captureDate 386 + photosByPath[saved.path] = photo 387 + } 388 + 389 + // Rebuild groups in saved order 390 + var photosByGroup: [String: [Photo]] = [:] 391 + for saved in savedPhotos { 392 + guard let groupID = saved.groupID, let photo = photosByPath[saved.path] else { continue } 393 + photosByGroup[groupID, default: []].append(photo) 394 + } 395 + 396 + var rebuiltGroups: [PhotoGroup] = [] 397 + for groupID in groupOrder { 398 + guard let photos = photosByGroup[groupID], !photos.isEmpty else { continue } 399 + rebuiltGroups.append(PhotoGroup(photos: photos)) 400 + } 401 + 402 + // Add any ungrouped photos (shouldn't happen but safety) 403 + let groupedPaths = Set(savedPhotos.compactMap { $0.groupID != nil ? $0.path : nil }) 404 + let ungrouped = photosByPath.filter { !groupedPaths.contains($0.key) }.map(\.value) 405 + if !ungrouped.isEmpty { 406 + rebuiltGroups.append(PhotoGroup(photos: ungrouped)) 407 + } 408 + 409 + guard !rebuiltGroups.isEmpty else { return false } 410 + 411 + self.groups = rebuiltGroups 412 + db.loadSettings(into: self) 413 + 414 + // Clamp navigation indices 415 + selectedGroupIndex = min(selectedGroupIndex, groups.count - 1) 416 + if let group = selectedGroup { 417 + selectedPhotoIndex = min(selectedPhotoIndex, group.photos.count - 1) 418 + } 419 + 420 + return true 317 421 } 318 422 }
+2
cull/Services/PhotoImporter.swift
··· 123 123 var pairedPixelWidth: Int = 0 124 124 var pairedPixelHeight: Int = 0 125 125 var pairedFileSize: Int64 = 0 126 + 127 + nonisolated init() {} 126 128 } 127 129 128 130 /// Read all metadata from a single CGImageSource open — date, dimensions, file size, paired metadata
+16 -8
cull/Services/ThumbnailCache.swift
··· 47 47 let diskPath = diskCacheURL.appendingPathComponent(stableDiskKey(for: photo.url)) 48 48 let pixelSize = maxPixelSize 49 49 50 - let image: NSImage? = await Task.detached(priority: .userInitiated) { () -> NSImage? in 50 + let image: NSImage? = await Task.detached { () -> NSImage? in 51 51 if let diskImage = NSImage(contentsOf: diskPath) { 52 52 return diskImage 53 53 } ··· 71 71 72 72 let loadURL = photo.imageURL 73 73 74 - let image: NSImage? = await Task.detached(priority: .userInitiated) { () -> NSImage? in 74 + let image: NSImage? = await Task.detached { () -> NSImage? in 75 75 Self.loadFullPreviewSync(from: loadURL) 76 76 }.value 77 77 ··· 162 162 } 163 163 for await (key, image) in group { 164 164 if let image { 165 - await MainActor.run { mc.setObject(image, forKey: key as NSString) } 165 + // NSCache is thread-safe, no need for MainActor 166 + mc.setObject(image, forKey: key as NSString) 166 167 } 167 168 } 168 169 } ··· 224 225 for batchStart in stride(from: 0, to: work.count, by: 4) { 225 226 guard !Task.isCancelled else { return } 226 227 let batch = Array(work[batchStart..<min(batchStart + 4, work.count)]) 227 - await withTaskGroup(of: (String, NSImage?).self) { group in 228 + let batchKeys = await withTaskGroup(of: (String, NSImage?).self, returning: [String].self) { group in 228 229 for (key, loadURL) in batch { 229 230 group.addTask { 230 231 guard !Task.isCancelled else { return (key, nil) } 231 232 return (key, Self.loadFullPreviewSync(from: loadURL)) 232 233 } 233 234 } 235 + var keys: [String] = [] 234 236 for await (key, image) in group { 235 237 if let image { 236 - await MainActor.run { 237 - pc.setObject(image, forKey: key as NSString) 238 - self.previewKeys.insert(key) 239 - } 238 + pc.setObject(image, forKey: key as NSString) 239 + keys.append(key) 240 + } 241 + } 242 + return keys 243 + } 244 + if !batchKeys.isEmpty { 245 + await MainActor.run { [batchKeys] in 246 + for key in batchKeys { 247 + self.previewKeys.insert(key) 240 248 } 241 249 } 242 250 }
+348
cull/Services/WorkspaceDB.swift
··· 1 + import CoreGraphics 2 + import Foundation 3 + import SQLite3 4 + 5 + /// Persists workspace state (ratings, flags, analysis, groups) in a SQLite database 6 + /// stored as `.cull.db` in the source photo folder. 7 + final class WorkspaceDB: @unchecked Sendable { 8 + private var db: OpaquePointer? 9 + private let dbURL: URL 10 + 11 + init?(folder: URL) { 12 + self.dbURL = folder.appendingPathComponent(".cull.db") 13 + guard sqlite3_open(dbURL.path, &db) == SQLITE_OK else { return nil } 14 + 15 + // WAL mode for better concurrent read/write 16 + exec("PRAGMA journal_mode=WAL") 17 + exec("PRAGMA synchronous=NORMAL") 18 + 19 + createTables() 20 + } 21 + 22 + deinit { 23 + sqlite3_close(db) 24 + } 25 + 26 + // MARK: - Schema 27 + 28 + private func createTables() { 29 + exec(""" 30 + CREATE TABLE IF NOT EXISTS photos ( 31 + path TEXT PRIMARY KEY, 32 + paired_path TEXT, 33 + rating INTEGER DEFAULT 0, 34 + flag TEXT DEFAULT 'none', 35 + blur_score REAL, 36 + face_sharpness REAL, 37 + face_regions TEXT, 38 + pixel_width INTEGER DEFAULT 0, 39 + pixel_height INTEGER DEFAULT 0, 40 + file_size INTEGER DEFAULT 0, 41 + paired_pixel_width INTEGER DEFAULT 0, 42 + paired_pixel_height INTEGER DEFAULT 0, 43 + paired_file_size INTEGER DEFAULT 0, 44 + capture_date REAL, 45 + group_id TEXT 46 + ) 47 + """) 48 + 49 + exec(""" 50 + CREATE TABLE IF NOT EXISTS groups ( 51 + group_id TEXT PRIMARY KEY, 52 + sort_order INTEGER 53 + ) 54 + """) 55 + 56 + exec(""" 57 + CREATE TABLE IF NOT EXISTS settings ( 58 + key TEXT PRIMARY KEY, 59 + value TEXT 60 + ) 61 + """) 62 + } 63 + 64 + // MARK: - Save 65 + 66 + func savePhotos(_ photos: [Photo], sourceFolder: URL) { 67 + exec("BEGIN TRANSACTION") 68 + let stmt = prepare(""" 69 + INSERT OR REPLACE INTO photos 70 + (path, paired_path, rating, flag, blur_score, face_sharpness, face_regions, 71 + pixel_width, pixel_height, file_size, 72 + paired_pixel_width, paired_pixel_height, paired_file_size, capture_date, group_id) 73 + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) 74 + """) 75 + defer { sqlite3_finalize(stmt) } 76 + 77 + for photo in photos { 78 + let relativePath = photo.url.relativePath(from: sourceFolder) 79 + let pairedPath = photo.pairedURL?.relativePath(from: sourceFolder) 80 + let flagStr = flagToString(photo.flag) 81 + let regionsJSON = encodeRegions(photo.faceRegions) 82 + 83 + sqlite3_reset(stmt) 84 + bind(stmt, 1, relativePath) 85 + bind(stmt, 2, pairedPath) 86 + bind(stmt, 3, photo.rating) 87 + bind(stmt, 4, flagStr) 88 + bind(stmt, 5, photo.blurScore) 89 + bind(stmt, 6, photo.faceSharpness) 90 + bind(stmt, 7, regionsJSON) 91 + bind(stmt, 8, photo.pixelWidth) 92 + bind(stmt, 9, photo.pixelHeight) 93 + bind(stmt, 10, photo.fileSize) 94 + bind(stmt, 11, photo.pairedPixelWidth) 95 + bind(stmt, 12, photo.pairedPixelHeight) 96 + bind(stmt, 13, photo.pairedFileSize) 97 + bind(stmt, 14, photo.captureDate?.timeIntervalSinceReferenceDate) 98 + bind(stmt, 15, nil as String?) // group_id set separately 99 + sqlite3_step(stmt) 100 + } 101 + exec("COMMIT") 102 + } 103 + 104 + func saveGroups(_ groups: [PhotoGroup], sourceFolder: URL) { 105 + exec("BEGIN TRANSACTION") 106 + exec("DELETE FROM groups") 107 + 108 + let groupStmt = prepare("INSERT INTO groups (group_id, sort_order) VALUES (?,?)") 109 + let photoStmt = prepare("UPDATE photos SET group_id = ? WHERE path = ?") 110 + defer { 111 + sqlite3_finalize(groupStmt) 112 + sqlite3_finalize(photoStmt) 113 + } 114 + 115 + for (i, group) in groups.enumerated() { 116 + let groupID = group.id.uuidString 117 + sqlite3_reset(groupStmt) 118 + bind(groupStmt, 1, groupID) 119 + bind(groupStmt, 2, i) 120 + sqlite3_step(groupStmt) 121 + 122 + for photo in group.photos { 123 + sqlite3_reset(photoStmt) 124 + bind(photoStmt, 1, groupID) 125 + bind(photoStmt, 2, photo.url.relativePath(from: sourceFolder)) 126 + sqlite3_step(photoStmt) 127 + } 128 + } 129 + exec("COMMIT") 130 + } 131 + 132 + func saveSetting(_ key: String, _ value: String?) { 133 + if let value { 134 + let stmt = prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?,?)") 135 + defer { sqlite3_finalize(stmt) } 136 + bind(stmt, 1, key) 137 + bind(stmt, 2, value) 138 + sqlite3_step(stmt) 139 + } else { 140 + let stmt = prepare("DELETE FROM settings WHERE key = ?") 141 + defer { sqlite3_finalize(stmt) } 142 + bind(stmt, 1, key) 143 + sqlite3_step(stmt) 144 + } 145 + } 146 + 147 + func saveSettings(session: CullSession) { 148 + saveSetting("selectedGroupIndex", "\(session.selectedGroupIndex)") 149 + saveSetting("selectedPhotoIndex", "\(session.selectedPhotoIndex)") 150 + saveSetting("hidePicks", session.hidePicks ? "1" : "0") 151 + saveSetting("hideRejects", session.hideRejects ? "1" : "0") 152 + saveSetting("hideUnrated", session.hideUnrated ? "1" : "0") 153 + saveSetting("hiddenRatings", session.hiddenRatings.map(String.init).joined(separator: ",")) 154 + saveSetting("importRecursive", session.importRecursive ? "1" : "0") 155 + } 156 + 157 + // MARK: - Load 158 + 159 + struct SavedPhoto { 160 + let path: String 161 + let pairedPath: String? 162 + let rating: Int 163 + let flag: PhotoFlag 164 + let blurScore: Double? 165 + let faceSharpness: Double? 166 + let faceRegions: [CGRect] 167 + let pixelWidth: Int 168 + let pixelHeight: Int 169 + let fileSize: Int64 170 + let pairedPixelWidth: Int 171 + let pairedPixelHeight: Int 172 + let pairedFileSize: Int64 173 + let captureDate: Date? 174 + let groupID: String? 175 + } 176 + 177 + func loadPhotos() -> [SavedPhoto] { 178 + let stmt = prepare("SELECT * FROM photos") 179 + defer { sqlite3_finalize(stmt) } 180 + 181 + var results: [SavedPhoto] = [] 182 + while sqlite3_step(stmt) == SQLITE_ROW { 183 + let path = getString(stmt, 0) ?? "" 184 + let pairedPath = getString(stmt, 1) 185 + let rating = Int(sqlite3_column_int(stmt, 2)) 186 + let flag = stringToFlag(getString(stmt, 3) ?? "none") 187 + let blurScore = getOptionalDouble(stmt, 4) 188 + let faceSharpness = getOptionalDouble(stmt, 5) 189 + let regionsJSON = getString(stmt, 6) 190 + let pixelWidth = Int(sqlite3_column_int(stmt, 7)) 191 + let pixelHeight = Int(sqlite3_column_int(stmt, 8)) 192 + let fileSize = sqlite3_column_int64(stmt, 9) 193 + let pairedPixelWidth = Int(sqlite3_column_int(stmt, 10)) 194 + let pairedPixelHeight = Int(sqlite3_column_int(stmt, 11)) 195 + let pairedFileSize = sqlite3_column_int64(stmt, 12) 196 + let captureDateInterval = getOptionalDouble(stmt, 13) 197 + let groupID = getString(stmt, 14) 198 + 199 + results.append(SavedPhoto( 200 + path: path, 201 + pairedPath: pairedPath, 202 + rating: rating, 203 + flag: flag, 204 + blurScore: blurScore, 205 + faceSharpness: faceSharpness, 206 + faceRegions: decodeRegions(regionsJSON), 207 + pixelWidth: pixelWidth, 208 + pixelHeight: pixelHeight, 209 + fileSize: fileSize, 210 + pairedPixelWidth: pairedPixelWidth, 211 + pairedPixelHeight: pairedPixelHeight, 212 + pairedFileSize: pairedFileSize, 213 + captureDate: captureDateInterval.map { Date(timeIntervalSinceReferenceDate: $0) }, 214 + groupID: groupID 215 + )) 216 + } 217 + return results 218 + } 219 + 220 + func loadGroupOrder() -> [String] { 221 + let stmt = prepare("SELECT group_id FROM groups ORDER BY sort_order") 222 + defer { sqlite3_finalize(stmt) } 223 + var ids: [String] = [] 224 + while sqlite3_step(stmt) == SQLITE_ROW { 225 + if let id = getString(stmt, 0) { ids.append(id) } 226 + } 227 + return ids 228 + } 229 + 230 + func loadSetting(_ key: String) -> String? { 231 + let stmt = prepare("SELECT value FROM settings WHERE key = ?") 232 + defer { sqlite3_finalize(stmt) } 233 + bind(stmt, 1, key) 234 + guard sqlite3_step(stmt) == SQLITE_ROW else { return nil } 235 + return getString(stmt, 0) 236 + } 237 + 238 + func loadSettings(into session: CullSession) { 239 + if let v = loadSetting("selectedGroupIndex"), let i = Int(v) { session.selectedGroupIndex = i } 240 + if let v = loadSetting("selectedPhotoIndex"), let i = Int(v) { session.selectedPhotoIndex = i } 241 + if let v = loadSetting("hidePicks") { session.hidePicks = v == "1" } 242 + if let v = loadSetting("hideRejects") { session.hideRejects = v == "1" } 243 + if let v = loadSetting("hideUnrated") { session.hideUnrated = v == "1" } 244 + if let v = loadSetting("hiddenRatings"), !v.isEmpty { 245 + session.hiddenRatings = Set(v.split(separator: ",").compactMap { Int($0) }) 246 + } 247 + if let v = loadSetting("importRecursive") { session.importRecursive = v == "1" } 248 + } 249 + 250 + /// Returns true if this database has cached photo data 251 + var hasCachedData: Bool { 252 + let stmt = prepare("SELECT COUNT(*) FROM photos") 253 + defer { sqlite3_finalize(stmt) } 254 + guard sqlite3_step(stmt) == SQLITE_ROW else { return false } 255 + return sqlite3_column_int(stmt, 0) > 0 256 + } 257 + 258 + // MARK: - Helpers 259 + 260 + @discardableResult 261 + private func exec(_ sql: String) -> Bool { 262 + sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK 263 + } 264 + 265 + private func prepare(_ sql: String) -> OpaquePointer? { 266 + var stmt: OpaquePointer? 267 + sqlite3_prepare_v2(db, sql, -1, &stmt, nil) 268 + return stmt 269 + } 270 + 271 + private func bind(_ stmt: OpaquePointer?, _ index: Int32, _ value: String?) { 272 + if let value { 273 + sqlite3_bind_text(stmt, index, (value as NSString).utf8String, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) 274 + } else { 275 + sqlite3_bind_null(stmt, index) 276 + } 277 + } 278 + 279 + private func bind(_ stmt: OpaquePointer?, _ index: Int32, _ value: Int) { 280 + sqlite3_bind_int(stmt, index, Int32(value)) 281 + } 282 + 283 + private func bind(_ stmt: OpaquePointer?, _ index: Int32, _ value: Int64) { 284 + sqlite3_bind_int64(stmt, index, value) 285 + } 286 + 287 + private func bind(_ stmt: OpaquePointer?, _ index: Int32, _ value: Double?) { 288 + if let value { 289 + sqlite3_bind_double(stmt, index, value) 290 + } else { 291 + sqlite3_bind_null(stmt, index) 292 + } 293 + } 294 + 295 + private func getString(_ stmt: OpaquePointer?, _ index: Int32) -> String? { 296 + guard let cStr = sqlite3_column_text(stmt, index) else { return nil } 297 + return String(cString: cStr) 298 + } 299 + 300 + private func getOptionalDouble(_ stmt: OpaquePointer?, _ index: Int32) -> Double? { 301 + if sqlite3_column_type(stmt, index) == SQLITE_NULL { return nil } 302 + return sqlite3_column_double(stmt, index) 303 + } 304 + 305 + private func flagToString(_ flag: PhotoFlag) -> String { 306 + switch flag { 307 + case .none: "none" 308 + case .pick: "pick" 309 + case .reject: "reject" 310 + } 311 + } 312 + 313 + private func stringToFlag(_ str: String) -> PhotoFlag { 314 + switch str { 315 + case "pick": .pick 316 + case "reject": .reject 317 + default: .none 318 + } 319 + } 320 + 321 + private func encodeRegions(_ regions: [CGRect]) -> String? { 322 + guard !regions.isEmpty else { return nil } 323 + let arrays = regions.map { [Double($0.origin.x), Double($0.origin.y), Double($0.width), Double($0.height)] } 324 + guard let data = try? JSONSerialization.data(withJSONObject: arrays) else { return nil } 325 + return String(data: data, encoding: .utf8) 326 + } 327 + 328 + private func decodeRegions(_ json: String?) -> [CGRect] { 329 + guard let json, let data = json.data(using: .utf8), 330 + let arrays = try? JSONSerialization.jsonObject(with: data) as? [[Double]] else { return [] } 331 + return arrays.compactMap { arr in 332 + guard arr.count == 4 else { return nil } 333 + return CGRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3]) 334 + } 335 + } 336 + } 337 + 338 + extension URL { 339 + func relativePath(from base: URL) -> String { 340 + let basePath = base.standardizedFileURL.path 341 + let selfPath = self.standardizedFileURL.path 342 + if selfPath.hasPrefix(basePath) { 343 + let relative = String(selfPath.dropFirst(basePath.count)) 344 + return relative.hasPrefix("/") ? String(relative.dropFirst()) : relative 345 + } 346 + return selfPath 347 + } 348 + }
+49
cull/Views/ContentView.swift
··· 54 54 55 55 Task { 56 56 do { 57 + // Try loading from workspace first 58 + if s.openWorkspace(folder: url) { 59 + await MainActor.run { s.importStatus = "Loading from workspace..." } 60 + let allPhotos = s.allPhotos 61 + 62 + // Still need to load thumbnails and previews 63 + await MainActor.run { s.importStatus = "Loading thumbnails..." } 64 + await c.preloadAllThumbnails(photos: allPhotos) { p in 65 + await MainActor.run { 66 + withAnimation(.linear(duration: 0.2)) { 67 + s.importProgress = p * 0.7 68 + } 69 + } 70 + } 71 + 72 + await MainActor.run { s.importStatus = "Loading previews..." } 73 + let ahead = Array(allPhotos.prefix(30)) 74 + let behind = Array(allPhotos.suffix(30)) 75 + let initialPreviews = ahead + behind.reversed() 76 + await c.preloadAllPreviews(photos: initialPreviews) { p in 77 + await MainActor.run { 78 + withAnimation(.linear(duration: 0.2)) { 79 + s.importProgress = 0.7 + p * 0.3 80 + } 81 + } 82 + } 83 + 84 + await MainActor.run { 85 + s.importProgress = 1.0 86 + s.isImporting = false 87 + } 88 + return 89 + } 90 + 91 + // No workspace — full import 92 + _ = WorkspaceDB(folder: url).map { s.workspace = $0 } 93 + 57 94 await MainActor.run { s.importStatus = "Scanning photos..." } 58 95 let result = try await PhotoImporter.importFolder(url, recursive: s.importRecursive) 59 96 ··· 142 179 s.selectedGroupIndex = 0 143 180 s.selectedPhotoIndex = 0 144 181 s.isImporting = false 182 + s.saveWorkspace() 145 183 } 146 184 } catch { 147 185 await MainActor.run { ··· 224 262 225 263 ToolbarItem(placement: .automatic) { 226 264 HStack(spacing: 2) { 265 + ToolbarFilterButton( 266 + activeIcon: "circle.slash", 267 + inactiveIcon: "circle.slash", 268 + isActive: session.selectedPhoto?.rating == 0, 269 + isFiltered: session.hideUnrated, 270 + activeColor: .secondary, 271 + action: { session.clearRatingAndFlag() }, 272 + filterAction: { session.toggleUnratedFilter() }, 273 + help: "Unrated (0) · ⌘Click to filter" 274 + ) 275 + 227 276 ForEach(1...5, id: \.self) { star in 228 277 let isActive = star <= (session.selectedPhoto?.rating ?? 0) 229 278 let isFiltered = session.hiddenRatings.contains(star)
+4
cull/cull.xcodeproj/project.pbxproj
··· 24 24 0B0EC2972F722491004523FA /* PhotoExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B0EC27D2F722491004523FA /* PhotoExporter.swift */; }; 25 25 0B0EC2982F722491004523FA /* ThumbnailCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B0EC2812F722491004523FA /* ThumbnailCache.swift */; }; 26 26 0B0EC2A12F72570F004523FA /* icon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 0B0EC2A02F72570F004523FA /* icon.icon */; }; 27 + 0B0EC2A52F727789004523FA /* WorkspaceDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B0EC2A42F727789004523FA /* WorkspaceDB.swift */; }; 27 28 /* End PBXBuildFile section */ 28 29 29 30 /* Begin PBXFileReference section */ ··· 45 46 0B0EC2882F722491004523FA /* PhotoViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoViewer.swift; sourceTree = "<group>"; }; 46 47 0B0EC2992F724FE5004523FA /* cull.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cull.app; sourceTree = BUILT_PRODUCTS_DIR; }; 47 48 0B0EC2A02F72570F004523FA /* icon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = icon.icon; sourceTree = "<group>"; }; 49 + 0B0EC2A42F727789004523FA /* WorkspaceDB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceDB.swift; sourceTree = "<group>"; }; 48 50 /* End PBXFileReference section */ 49 51 50 52 /* Begin PBXFrameworksBuildPhase section */ ··· 84 86 0B0EC2822F722491004523FA /* Services */ = { 85 87 isa = PBXGroup; 86 88 children = ( 89 + 0B0EC2A42F727789004523FA /* WorkspaceDB.swift */, 87 90 0B0EC27D2F722491004523FA /* PhotoExporter.swift */, 88 91 0B0EC27E2F722491004523FA /* PhotoImporter.swift */, 89 92 0B0EC27F2F722491004523FA /* QualityAnalyzer.swift */, ··· 180 183 buildActionMask = 2147483647; 181 184 files = ( 182 185 0B0EC28A2F722491004523FA /* ImportView.swift in Sources */, 186 + 0B0EC2A52F727789004523FA /* WorkspaceDB.swift in Sources */, 183 187 0B0EC28B2F722491004523FA /* ExportSheet.swift in Sources */, 184 188 0B0EC28C2F722491004523FA /* ShotGrouper.swift in Sources */, 185 189 0B0EC28D2F722491004523FA /* Photo.swift in Sources */,