From 4118914976393252ea5cb0616486a4c1602dec80 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Tue, 24 Mar 2026 15:43:20 +0800 Subject: [PATCH] feat: add workspaces --- cull/Models/CullSession.swift | 104 +++++++++ cull/Services/PhotoImporter.swift | 2 + cull/Services/ThumbnailCache.swift | 24 +- cull/Services/WorkspaceDB.swift | 348 ++++++++++++++++++++++++++++ cull/Views/ContentView.swift | 49 ++++ cull/cull.xcodeproj/project.pbxproj | 4 + 6 files changed, 523 insertions(+), 8 deletions(-) create mode 100644 cull/Services/WorkspaceDB.swift diff --git a/cull/Models/CullSession.swift b/cull/Models/CullSession.swift index 709bf29..3b4fc63 100644 --- a/cull/Models/CullSession.swift +++ b/cull/Models/CullSession.swift @@ -17,12 +17,15 @@ final class CullSession { var importStatus: String = "" var undoManager: UndoManager? + var workspace: WorkspaceDB? + private var saveTask: Task? // Remember cursor position per group private var groupCursorPositions: [UUID: Int] = [:] // Filters: command-click to toggle hiding photos with these attributes var hiddenRatings: Set = [] // ratings to hide (1-5) + var hideUnrated: Bool = false var hidePicks: Bool = false var hideRejects: Bool = false @@ -33,21 +36,31 @@ final class CullSession { hiddenRatings.insert(rating) } ensureVisibleSelection() + scheduleSave() } func togglePickFilter() { hidePicks.toggle() ensureVisibleSelection() + scheduleSave() } func toggleRejectFilter() { hideRejects.toggle() ensureVisibleSelection() + scheduleSave() + } + + func toggleUnratedFilter() { + hideUnrated.toggle() + ensureVisibleSelection() + scheduleSave() } func isPhotoFiltered(_ photo: Photo) -> Bool { if hidePicks && photo.flag == .pick { return true } if hideRejects && photo.flag == .reject { return true } + if hideUnrated && photo.rating == 0 && photo.flag == .none { return true } if photo.rating > 0 && hiddenRatings.contains(photo.rating) { return true } return false } @@ -258,6 +271,7 @@ final class CullSession { session.applyPhotoState(photo, rating: oldRating, flag: oldFlag, actionName: actionName) } undoManager?.setActionName(actionName) + scheduleSave() } func setRating(_ rating: Int) { @@ -315,4 +329,94 @@ final class CullSession { private func resetZoom() { zoomFaceIndex = nil } + + // MARK: - Workspace persistence + + /// Debounced auto-save — coalesces rapid changes into a single write + func scheduleSave() { + saveTask?.cancel() + saveTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(500)) + guard !Task.isCancelled else { return } + self?.saveWorkspace() + } + } + + func saveWorkspace() { + guard let workspace, let sourceFolder else { return } + let allPhotos = groups.flatMap(\.photos) + workspace.savePhotos(allPhotos, sourceFolder: sourceFolder) + workspace.saveGroups(groups, sourceFolder: sourceFolder) + workspace.saveSettings(session: self) + } + + func openWorkspace(folder: URL) -> Bool { + guard let db = WorkspaceDB(folder: folder) else { return false } + self.workspace = db + + guard db.hasCachedData else { return false } + + let savedPhotos = db.loadPhotos() + let groupOrder = db.loadGroupOrder() + + // Rebuild photos keyed by relative path + var photosByPath: [String: Photo] = [:] + for saved in savedPhotos { + let url = folder.appendingPathComponent(saved.path) + guard FileManager.default.fileExists(atPath: url.path) else { continue } + let photo = Photo(url: url) + if let pairedPath = saved.pairedPath { + let pairedURL = folder.appendingPathComponent(pairedPath) + if FileManager.default.fileExists(atPath: pairedURL.path) { + photo.pairedURL = pairedURL + } + } + photo.rating = saved.rating + photo.flag = saved.flag + photo.blurScore = saved.blurScore + photo.faceSharpness = saved.faceSharpness + photo.faceRegions = saved.faceRegions + photo.pixelWidth = saved.pixelWidth + photo.pixelHeight = saved.pixelHeight + photo.fileSize = saved.fileSize + photo.pairedPixelWidth = saved.pairedPixelWidth + photo.pairedPixelHeight = saved.pairedPixelHeight + photo.pairedFileSize = saved.pairedFileSize + photo.captureDate = saved.captureDate + photosByPath[saved.path] = photo + } + + // Rebuild groups in saved order + var photosByGroup: [String: [Photo]] = [:] + for saved in savedPhotos { + guard let groupID = saved.groupID, let photo = photosByPath[saved.path] else { continue } + photosByGroup[groupID, default: []].append(photo) + } + + var rebuiltGroups: [PhotoGroup] = [] + for groupID in groupOrder { + guard let photos = photosByGroup[groupID], !photos.isEmpty else { continue } + rebuiltGroups.append(PhotoGroup(photos: photos)) + } + + // Add any ungrouped photos (shouldn't happen but safety) + let groupedPaths = Set(savedPhotos.compactMap { $0.groupID != nil ? $0.path : nil }) + let ungrouped = photosByPath.filter { !groupedPaths.contains($0.key) }.map(\.value) + if !ungrouped.isEmpty { + rebuiltGroups.append(PhotoGroup(photos: ungrouped)) + } + + guard !rebuiltGroups.isEmpty else { return false } + + self.groups = rebuiltGroups + db.loadSettings(into: self) + + // Clamp navigation indices + selectedGroupIndex = min(selectedGroupIndex, groups.count - 1) + if let group = selectedGroup { + selectedPhotoIndex = min(selectedPhotoIndex, group.photos.count - 1) + } + + return true + } } diff --git a/cull/Services/PhotoImporter.swift b/cull/Services/PhotoImporter.swift index 1c3e8ff..3cb7874 100644 --- a/cull/Services/PhotoImporter.swift +++ b/cull/Services/PhotoImporter.swift @@ -123,6 +123,8 @@ struct PhotoImporter { var pairedPixelWidth: Int = 0 var pairedPixelHeight: Int = 0 var pairedFileSize: Int64 = 0 + + nonisolated init() {} } /// Read all metadata from a single CGImageSource open — date, dimensions, file size, paired metadata diff --git a/cull/Services/ThumbnailCache.swift b/cull/Services/ThumbnailCache.swift index 0a2f8ca..729e50b 100644 --- a/cull/Services/ThumbnailCache.swift +++ b/cull/Services/ThumbnailCache.swift @@ -47,7 +47,7 @@ final class ThumbnailCache { let diskPath = diskCacheURL.appendingPathComponent(stableDiskKey(for: photo.url)) let pixelSize = maxPixelSize - let image: NSImage? = await Task.detached(priority: .userInitiated) { () -> NSImage? in + let image: NSImage? = await Task.detached { () -> NSImage? in if let diskImage = NSImage(contentsOf: diskPath) { return diskImage } @@ -71,7 +71,7 @@ final class ThumbnailCache { let loadURL = photo.imageURL - let image: NSImage? = await Task.detached(priority: .userInitiated) { () -> NSImage? in + let image: NSImage? = await Task.detached { () -> NSImage? in Self.loadFullPreviewSync(from: loadURL) }.value @@ -162,7 +162,8 @@ final class ThumbnailCache { } for await (key, image) in group { if let image { - await MainActor.run { mc.setObject(image, forKey: key as NSString) } + // NSCache is thread-safe, no need for MainActor + mc.setObject(image, forKey: key as NSString) } } } @@ -224,19 +225,26 @@ final class ThumbnailCache { for batchStart in stride(from: 0, to: work.count, by: 4) { guard !Task.isCancelled else { return } let batch = Array(work[batchStart.. [SavedPhoto] { + let stmt = prepare("SELECT * FROM photos") + defer { sqlite3_finalize(stmt) } + + var results: [SavedPhoto] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + let path = getString(stmt, 0) ?? "" + let pairedPath = getString(stmt, 1) + let rating = Int(sqlite3_column_int(stmt, 2)) + let flag = stringToFlag(getString(stmt, 3) ?? "none") + let blurScore = getOptionalDouble(stmt, 4) + let faceSharpness = getOptionalDouble(stmt, 5) + let regionsJSON = getString(stmt, 6) + let pixelWidth = Int(sqlite3_column_int(stmt, 7)) + let pixelHeight = Int(sqlite3_column_int(stmt, 8)) + let fileSize = sqlite3_column_int64(stmt, 9) + let pairedPixelWidth = Int(sqlite3_column_int(stmt, 10)) + let pairedPixelHeight = Int(sqlite3_column_int(stmt, 11)) + let pairedFileSize = sqlite3_column_int64(stmt, 12) + let captureDateInterval = getOptionalDouble(stmt, 13) + let groupID = getString(stmt, 14) + + results.append(SavedPhoto( + path: path, + pairedPath: pairedPath, + rating: rating, + flag: flag, + blurScore: blurScore, + faceSharpness: faceSharpness, + faceRegions: decodeRegions(regionsJSON), + pixelWidth: pixelWidth, + pixelHeight: pixelHeight, + fileSize: fileSize, + pairedPixelWidth: pairedPixelWidth, + pairedPixelHeight: pairedPixelHeight, + pairedFileSize: pairedFileSize, + captureDate: captureDateInterval.map { Date(timeIntervalSinceReferenceDate: $0) }, + groupID: groupID + )) + } + return results + } + + func loadGroupOrder() -> [String] { + let stmt = prepare("SELECT group_id FROM groups ORDER BY sort_order") + defer { sqlite3_finalize(stmt) } + var ids: [String] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + if let id = getString(stmt, 0) { ids.append(id) } + } + return ids + } + + func loadSetting(_ key: String) -> String? { + let stmt = prepare("SELECT value FROM settings WHERE key = ?") + defer { sqlite3_finalize(stmt) } + bind(stmt, 1, key) + guard sqlite3_step(stmt) == SQLITE_ROW else { return nil } + return getString(stmt, 0) + } + + func loadSettings(into session: CullSession) { + if let v = loadSetting("selectedGroupIndex"), let i = Int(v) { session.selectedGroupIndex = i } + if let v = loadSetting("selectedPhotoIndex"), let i = Int(v) { session.selectedPhotoIndex = i } + if let v = loadSetting("hidePicks") { session.hidePicks = v == "1" } + if let v = loadSetting("hideRejects") { session.hideRejects = v == "1" } + if let v = loadSetting("hideUnrated") { session.hideUnrated = v == "1" } + if let v = loadSetting("hiddenRatings"), !v.isEmpty { + session.hiddenRatings = Set(v.split(separator: ",").compactMap { Int($0) }) + } + if let v = loadSetting("importRecursive") { session.importRecursive = v == "1" } + } + + /// Returns true if this database has cached photo data + var hasCachedData: Bool { + let stmt = prepare("SELECT COUNT(*) FROM photos") + defer { sqlite3_finalize(stmt) } + guard sqlite3_step(stmt) == SQLITE_ROW else { return false } + return sqlite3_column_int(stmt, 0) > 0 + } + + // MARK: - Helpers + + @discardableResult + private func exec(_ sql: String) -> Bool { + sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK + } + + private func prepare(_ sql: String) -> OpaquePointer? { + var stmt: OpaquePointer? + sqlite3_prepare_v2(db, sql, -1, &stmt, nil) + return stmt + } + + private func bind(_ stmt: OpaquePointer?, _ index: Int32, _ value: String?) { + if let value { + sqlite3_bind_text(stmt, index, (value as NSString).utf8String, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + } else { + sqlite3_bind_null(stmt, index) + } + } + + private func bind(_ stmt: OpaquePointer?, _ index: Int32, _ value: Int) { + sqlite3_bind_int(stmt, index, Int32(value)) + } + + private func bind(_ stmt: OpaquePointer?, _ index: Int32, _ value: Int64) { + sqlite3_bind_int64(stmt, index, value) + } + + private func bind(_ stmt: OpaquePointer?, _ index: Int32, _ value: Double?) { + if let value { + sqlite3_bind_double(stmt, index, value) + } else { + sqlite3_bind_null(stmt, index) + } + } + + private func getString(_ stmt: OpaquePointer?, _ index: Int32) -> String? { + guard let cStr = sqlite3_column_text(stmt, index) else { return nil } + return String(cString: cStr) + } + + private func getOptionalDouble(_ stmt: OpaquePointer?, _ index: Int32) -> Double? { + if sqlite3_column_type(stmt, index) == SQLITE_NULL { return nil } + return sqlite3_column_double(stmt, index) + } + + private func flagToString(_ flag: PhotoFlag) -> String { + switch flag { + case .none: "none" + case .pick: "pick" + case .reject: "reject" + } + } + + private func stringToFlag(_ str: String) -> PhotoFlag { + switch str { + case "pick": .pick + case "reject": .reject + default: .none + } + } + + private func encodeRegions(_ regions: [CGRect]) -> String? { + guard !regions.isEmpty else { return nil } + let arrays = regions.map { [Double($0.origin.x), Double($0.origin.y), Double($0.width), Double($0.height)] } + guard let data = try? JSONSerialization.data(withJSONObject: arrays) else { return nil } + return String(data: data, encoding: .utf8) + } + + private func decodeRegions(_ json: String?) -> [CGRect] { + guard let json, let data = json.data(using: .utf8), + let arrays = try? JSONSerialization.jsonObject(with: data) as? [[Double]] else { return [] } + return arrays.compactMap { arr in + guard arr.count == 4 else { return nil } + return CGRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3]) + } + } +} + +extension URL { + func relativePath(from base: URL) -> String { + let basePath = base.standardizedFileURL.path + let selfPath = self.standardizedFileURL.path + if selfPath.hasPrefix(basePath) { + let relative = String(selfPath.dropFirst(basePath.count)) + return relative.hasPrefix("/") ? String(relative.dropFirst()) : relative + } + return selfPath + } +} diff --git a/cull/Views/ContentView.swift b/cull/Views/ContentView.swift index be68c4d..6b69df5 100644 --- a/cull/Views/ContentView.swift +++ b/cull/Views/ContentView.swift @@ -54,6 +54,43 @@ struct ContentView: View { Task { do { + // Try loading from workspace first + if s.openWorkspace(folder: url) { + await MainActor.run { s.importStatus = "Loading from workspace..." } + let allPhotos = s.allPhotos + + // Still need to load thumbnails and previews + await MainActor.run { s.importStatus = "Loading thumbnails..." } + await c.preloadAllThumbnails(photos: allPhotos) { p in + await MainActor.run { + withAnimation(.linear(duration: 0.2)) { + s.importProgress = p * 0.7 + } + } + } + + await MainActor.run { s.importStatus = "Loading previews..." } + let ahead = Array(allPhotos.prefix(30)) + let behind = Array(allPhotos.suffix(30)) + let initialPreviews = ahead + behind.reversed() + await c.preloadAllPreviews(photos: initialPreviews) { p in + await MainActor.run { + withAnimation(.linear(duration: 0.2)) { + s.importProgress = 0.7 + p * 0.3 + } + } + } + + await MainActor.run { + s.importProgress = 1.0 + s.isImporting = false + } + return + } + + // No workspace — full import + _ = WorkspaceDB(folder: url).map { s.workspace = $0 } + await MainActor.run { s.importStatus = "Scanning photos..." } let result = try await PhotoImporter.importFolder(url, recursive: s.importRecursive) @@ -142,6 +179,7 @@ struct ContentView: View { s.selectedGroupIndex = 0 s.selectedPhotoIndex = 0 s.isImporting = false + s.saveWorkspace() } } catch { await MainActor.run { @@ -224,6 +262,17 @@ struct ContentView: View { ToolbarItem(placement: .automatic) { HStack(spacing: 2) { + ToolbarFilterButton( + activeIcon: "circle.slash", + inactiveIcon: "circle.slash", + isActive: session.selectedPhoto?.rating == 0, + isFiltered: session.hideUnrated, + activeColor: .secondary, + action: { session.clearRatingAndFlag() }, + filterAction: { session.toggleUnratedFilter() }, + help: "Unrated (0) · ⌘Click to filter" + ) + ForEach(1...5, id: \.self) { star in let isActive = star <= (session.selectedPhoto?.rating ?? 0) let isFiltered = session.hiddenRatings.contains(star) diff --git a/cull/cull.xcodeproj/project.pbxproj b/cull/cull.xcodeproj/project.pbxproj index 828293f..64d77c9 100644 --- a/cull/cull.xcodeproj/project.pbxproj +++ b/cull/cull.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ 0B0EC2972F722491004523FA /* PhotoExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B0EC27D2F722491004523FA /* PhotoExporter.swift */; }; 0B0EC2982F722491004523FA /* ThumbnailCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B0EC2812F722491004523FA /* ThumbnailCache.swift */; }; 0B0EC2A12F72570F004523FA /* icon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 0B0EC2A02F72570F004523FA /* icon.icon */; }; + 0B0EC2A52F727789004523FA /* WorkspaceDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B0EC2A42F727789004523FA /* WorkspaceDB.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -45,6 +46,7 @@ 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; }; 0B0EC2A02F72570F004523FA /* icon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = icon.icon; sourceTree = ""; }; + 0B0EC2A42F727789004523FA /* WorkspaceDB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceDB.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -84,6 +86,7 @@ 0B0EC2822F722491004523FA /* Services */ = { isa = PBXGroup; children = ( + 0B0EC2A42F727789004523FA /* WorkspaceDB.swift */, 0B0EC27D2F722491004523FA /* PhotoExporter.swift */, 0B0EC27E2F722491004523FA /* PhotoImporter.swift */, 0B0EC27F2F722491004523FA /* QualityAnalyzer.swift */, @@ -180,6 +183,7 @@ buildActionMask = 2147483647; files = ( 0B0EC28A2F722491004523FA /* ImportView.swift in Sources */, + 0B0EC2A52F727789004523FA /* WorkspaceDB.swift in Sources */, 0B0EC28B2F722491004523FA /* ExportSheet.swift in Sources */, 0B0EC28C2F722491004523FA /* ShotGrouper.swift in Sources */, 0B0EC28D2F722491004523FA /* Photo.swift in Sources */, -- 2.51.2