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: make cache filter aware and group saving atomic

+47 -81
+15 -34
cull/Models/CullSession.swift
··· 221 221 222 222 // MARK: - Lookahead 223 223 224 - /// Returns the next N photos from the current position, wrapping around to start 224 + /// Returns the next N visible (unfiltered) photos from the current position, wrapping around to start 225 225 func photosAhead(_ count: Int) -> [Photo] { 226 - let all = allPhotos 227 - guard !all.isEmpty else { return [] } 228 - guard let currentIndex = flatIndex else { return [] } 229 - var result: [Photo] = [] 230 - for i in 1...min(count, all.count - 1) { 231 - result.append(all[(currentIndex + i) % all.count]) 226 + let visible = allPhotos.filter { !isPhotoFiltered($0) } 227 + guard !visible.isEmpty, count > 0 else { return [] } 228 + guard let current = selectedPhoto, 229 + let visibleIndex = visible.firstIndex(where: { $0.id == current.id }) else { return [] } 230 + return (1...min(count, visible.count - 1)).map { i in 231 + visible[(visibleIndex + i) % visible.count] 232 232 } 233 - return result 234 233 } 235 234 236 - /// Returns the previous N photos from the current position, wrapping around to end 235 + /// Returns the previous N visible (unfiltered) photos from the current position, wrapping around to end 237 236 func photosBehind(_ count: Int) -> [Photo] { 238 - let all = allPhotos 239 - guard !all.isEmpty else { return [] } 240 - guard let currentIndex = flatIndex else { return [] } 241 - var result: [Photo] = [] 242 - for i in 1...min(count, all.count - 1) { 243 - result.append(all[(currentIndex - i + all.count) % all.count]) 237 + let visible = allPhotos.filter { !isPhotoFiltered($0) } 238 + guard !visible.isEmpty, count > 0 else { return [] } 239 + guard let current = selectedPhoto, 240 + let visibleIndex = visible.firstIndex(where: { $0.id == current.id }) else { return [] } 241 + return (1...min(count, visible.count - 1)).map { i in 242 + visible[(visibleIndex - i + visible.count) % visible.count] 244 243 } 245 - return result 246 - } 247 - 248 - /// Current photo's index in the flat allPhotos array 249 - private var flatIndex: Int? { 250 - guard selectedPhoto != nil else { return nil } 251 - var idx = 0 252 - for gi in 0..<groups.count { 253 - for pi in 0..<groups[gi].photos.count { 254 - if gi == selectedGroupIndex && pi == selectedPhotoIndex { 255 - return idx 256 - } 257 - idx += 1 258 - } 259 - } 260 - return nil 261 244 } 262 245 263 246 // MARK: - Culling Actions ··· 347 330 348 331 func saveWorkspace() { 349 332 guard let workspace, let sourceFolder else { return } 350 - let allPhotos = groups.flatMap(\.photos) 351 - workspace.savePhotos(allPhotos, sourceFolder: sourceFolder) 352 - workspace.saveGroups(groups, sourceFolder: sourceFolder) 333 + workspace.savePhotosAndGroups(groups, sourceFolder: sourceFolder) 353 334 workspace.saveSettings(session: self) 354 335 } 355 336
+32 -47
cull/Services/WorkspaceDB.swift
··· 70 70 71 71 // MARK: - Save 72 72 73 - func savePhotos(_ photos: [Photo], sourceFolder: URL) { 73 + func savePhotosAndGroups(_ groups: [PhotoGroup], sourceFolder: URL) { 74 + // Photos and groups are saved in a single transaction so group_id is always consistent. 75 + // The old two-step approach (savePhotos with NULL group_id, then saveGroups updating them) 76 + // could lose all group assignments if the process was killed between the two writes. 74 77 exec("BEGIN TRANSACTION") 75 - let stmt = prepare(""" 78 + 79 + let photoStmt = prepare(""" 76 80 INSERT OR REPLACE INTO photos 77 81 (path, paired_path, rating, flag, blur_score, face_sharpness, face_regions, 78 82 pixel_width, pixel_height, file_size, ··· 80 84 eye_aspect_ratios) 81 85 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) 82 86 """) 83 - defer { sqlite3_finalize(stmt) } 84 - 85 - for photo in photos { 86 - let relativePath = photo.url.relativePath(from: sourceFolder) 87 - let pairedPath = photo.pairedURL?.relativePath(from: sourceFolder) 88 - let flagStr = flagToString(photo.flag) 89 - let regionsJSON = encodeRegions(photo.faceRegions) 87 + defer { sqlite3_finalize(photoStmt) } 90 88 91 - sqlite3_reset(stmt) 92 - bind(stmt, 1, relativePath) 93 - bind(stmt, 2, pairedPath) 94 - bind(stmt, 3, photo.rating) 95 - bind(stmt, 4, flagStr) 96 - bind(stmt, 5, photo.blurScore) 97 - bind(stmt, 6, photo.faceSharpness) 98 - bind(stmt, 7, regionsJSON) 99 - bind(stmt, 8, photo.pixelWidth) 100 - bind(stmt, 9, photo.pixelHeight) 101 - bind(stmt, 10, photo.fileSize) 102 - bind(stmt, 11, photo.pairedPixelWidth) 103 - bind(stmt, 12, photo.pairedPixelHeight) 104 - bind(stmt, 13, photo.pairedFileSize) 105 - bind(stmt, 14, photo.captureDate?.timeIntervalSinceReferenceDate) 106 - bind(stmt, 15, nil as String?) // group_id set separately 107 - bind(stmt, 16, encodeDoubles(photo.eyeAspectRatios)) 108 - sqlite3_step(stmt) 89 + for group in groups { 90 + for photo in group.photos { 91 + let relativePath = photo.url.relativePath(from: sourceFolder) 92 + sqlite3_reset(photoStmt) 93 + bind(photoStmt, 1, relativePath) 94 + bind(photoStmt, 2, photo.pairedURL?.relativePath(from: sourceFolder)) 95 + bind(photoStmt, 3, photo.rating) 96 + bind(photoStmt, 4, flagToString(photo.flag)) 97 + bind(photoStmt, 5, photo.blurScore) 98 + bind(photoStmt, 6, photo.faceSharpness) 99 + bind(photoStmt, 7, encodeRegions(photo.faceRegions)) 100 + bind(photoStmt, 8, photo.pixelWidth) 101 + bind(photoStmt, 9, photo.pixelHeight) 102 + bind(photoStmt, 10, photo.fileSize) 103 + bind(photoStmt, 11, photo.pairedPixelWidth) 104 + bind(photoStmt, 12, photo.pairedPixelHeight) 105 + bind(photoStmt, 13, photo.pairedFileSize) 106 + bind(photoStmt, 14, photo.captureDate?.timeIntervalSinceReferenceDate) 107 + bind(photoStmt, 15, group.id.uuidString) 108 + bind(photoStmt, 16, encodeDoubles(photo.eyeAspectRatios)) 109 + sqlite3_step(photoStmt) 110 + } 109 111 } 110 - exec("COMMIT") 111 - } 112 112 113 - func saveGroups(_ groups: [PhotoGroup], sourceFolder: URL) { 114 - exec("BEGIN TRANSACTION") 115 113 exec("DELETE FROM groups") 116 - 117 114 let groupStmt = prepare("INSERT INTO groups (group_id, sort_order) VALUES (?,?)") 118 - let photoStmt = prepare("UPDATE photos SET group_id = ? WHERE path = ?") 119 - defer { 120 - sqlite3_finalize(groupStmt) 121 - sqlite3_finalize(photoStmt) 122 - } 123 - 115 + defer { sqlite3_finalize(groupStmt) } 124 116 for (i, group) in groups.enumerated() { 125 - let groupID = group.id.uuidString 126 117 sqlite3_reset(groupStmt) 127 - bind(groupStmt, 1, groupID) 118 + bind(groupStmt, 1, group.id.uuidString) 128 119 bind(groupStmt, 2, i) 129 120 sqlite3_step(groupStmt) 121 + } 130 122 131 - for photo in group.photos { 132 - sqlite3_reset(photoStmt) 133 - bind(photoStmt, 1, groupID) 134 - bind(photoStmt, 2, photo.url.relativePath(from: sourceFolder)) 135 - sqlite3_step(photoStmt) 136 - } 137 - } 138 123 exec("COMMIT") 139 124 } 140 125