A macOS CLI tool that exports original photos and videos from the macOS Photos library using PhotoKit.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Photos.sqlite enrichment for metadata PhotoKit doesn't expose

Adds PhotosDatabase that reads keywords, people/faces, descriptions,
and edit details from Photos.sqlite. These fields aren't available
through PhotoKit but are essential for complete asset metadata.

Extends AssetInfo with enrichment fields and a uuid computed property
that extracts the UUID from PhotoKit's localIdentifier format for
joining against Photos.sqlite ZUUID. Uses safeQuery pattern for
resilience across macOS schema versions.

+559 -21
+49 -5
Sources/LadderKit/AssetInfo.swift
··· 1 1 import Foundation 2 2 3 - /// Lightweight metadata about a photo or video asset, populated from PhotoKit. 3 + /// Lightweight metadata about a photo or video asset. 4 4 /// 5 - /// This struct is used for asset discovery and filtering — it carries enough 6 - /// information to decide whether an asset needs backup and to build the 7 - /// metadata JSON, without loading the asset's pixel data. 5 + /// Core fields (identifier, dates, dimensions, location, etc.) are populated 6 + /// from PhotoKit. Enrichment fields (keywords, people, description, editor) 7 + /// come from Photos.sqlite via ``PhotosDatabase`` since PhotoKit doesn't 8 + /// expose them. 8 9 public struct AssetInfo: Sendable { 10 + // MARK: - PhotoKit fields 11 + 9 12 public let identifier: String 10 13 public let creationDate: Date? 11 14 public let kind: AssetKind ··· 19 22 public let hasEdit: Bool 20 23 public let albums: [AlbumInfo] 21 24 25 + // MARK: - Enrichment fields (from Photos.sqlite) 26 + 27 + public var keywords: [String] 28 + public var people: [PersonInfo] 29 + public var assetDescription: String? 30 + public var editedAt: Date? 31 + public var editor: String? 32 + 33 + /// The UUID portion of the PhotoKit local identifier. 34 + /// 35 + /// PhotoKit `localIdentifier` is formatted as `UUID/L0/001`. 36 + /// Photos.sqlite `ZUUID` is just the UUID. This property extracts 37 + /// the UUID prefix for joining between the two systems. 38 + public var uuid: String { 39 + if let slashIndex = identifier.firstIndex(of: "/") { 40 + return String(identifier[identifier.startIndex..<slashIndex]) 41 + } 42 + return identifier 43 + } 44 + 22 45 public init( 23 46 identifier: String, 24 47 creationDate: Date?, ··· 31 54 originalFilename: String?, 32 55 uniformTypeIdentifier: String?, 33 56 hasEdit: Bool, 34 - albums: [AlbumInfo] 57 + albums: [AlbumInfo], 58 + keywords: [String] = [], 59 + people: [PersonInfo] = [], 60 + assetDescription: String? = nil, 61 + editedAt: Date? = nil, 62 + editor: String? = nil 35 63 ) { 36 64 self.identifier = identifier 37 65 self.creationDate = creationDate ··· 45 73 self.uniformTypeIdentifier = uniformTypeIdentifier 46 74 self.hasEdit = hasEdit 47 75 self.albums = albums 76 + self.keywords = keywords 77 + self.people = people 78 + self.assetDescription = assetDescription 79 + self.editedAt = editedAt 80 + self.editor = editor 48 81 } 49 82 } 50 83 ··· 64 97 self.title = title 65 98 } 66 99 } 100 + 101 + /// Reference to a recognized person in the asset. 102 + public struct PersonInfo: Sendable, Codable, Equatable { 103 + public let uuid: String 104 + public let displayName: String 105 + 106 + public init(uuid: String, displayName: String) { 107 + self.uuid = uuid 108 + self.displayName = displayName 109 + } 110 + }
+286
Sources/LadderKit/PhotosDatabase.swift
··· 1 + import Foundation 2 + import SQLite3 3 + 4 + /// Reads enrichment metadata from Photos.sqlite that PhotoKit doesn't expose: 5 + /// keywords, people/faces, descriptions, and edit details. 6 + /// 7 + /// Opens the database read-only and closes it after building the enrichment maps. 8 + /// Uses `safeQuery` for resilience across macOS versions where table schemas differ. 9 + public struct PhotosDatabase: Sendable { 10 + /// Default path to the Photos library database. 11 + public static let defaultPath: String = { 12 + let home = FileManager.default.homeDirectoryForCurrentUser.path 13 + return "\(home)/Pictures/Photos Library.photoslibrary/database/Photos.sqlite" 14 + }() 15 + 16 + /// CoreData epoch (2001-01-01) offset from Unix epoch in seconds. 17 + static let coreDataEpochOffset: TimeInterval = 978_307_200 18 + 19 + /// All enrichment data, keyed by Photos.sqlite ZUUID. 20 + public struct EnrichmentData: Sendable { 21 + public let keywords: [String: [String]] 22 + public let people: [String: [PersonInfo]] 23 + public let descriptions: [String: String] 24 + public let edits: [String: EditInfo] 25 + 26 + public static let empty = EnrichmentData( 27 + keywords: [:], people: [:], descriptions: [:], edits: [:] 28 + ) 29 + } 30 + 31 + /// Edit information from Photos.sqlite. 32 + public struct EditInfo: Sendable { 33 + public let editedAt: Date? 34 + public let editor: String 35 + } 36 + 37 + private init() {} 38 + 39 + /// Read all enrichment data from Photos.sqlite. 40 + /// 41 + /// Opens the database read-only, runs enrichment queries, and closes it. 42 + /// Returns `.empty` if the database cannot be opened. 43 + public static func readEnrichment( 44 + dbPath: String = defaultPath 45 + ) -> EnrichmentData { 46 + guard let db = openDatabase(path: dbPath) else { 47 + return .empty 48 + } 49 + defer { sqlite3_close(db) } 50 + 51 + let uuidMap = buildUUIDMap(db: db) 52 + 53 + return EnrichmentData( 54 + keywords: buildKeywordMap(db: db, uuidMap: uuidMap), 55 + people: buildPeopleMap(db: db, uuidMap: uuidMap), 56 + descriptions: buildDescriptionMap(db: db, uuidMap: uuidMap), 57 + edits: buildEditMap(db: db, uuidMap: uuidMap) 58 + ) 59 + } 60 + 61 + /// Apply enrichment data to an array of assets in-place. 62 + /// 63 + /// Matches assets by their `uuid` property (extracted from PhotoKit's 64 + /// localIdentifier) against the enrichment maps keyed by Photos.sqlite ZUUID. 65 + public static func enrich( 66 + _ assets: inout [AssetInfo], 67 + with data: EnrichmentData 68 + ) { 69 + for i in assets.indices { 70 + let uuid = assets[i].uuid 71 + if let kw = data.keywords[uuid] { 72 + assets[i].keywords = kw 73 + } 74 + if let ppl = data.people[uuid] { 75 + assets[i].people = ppl 76 + } 77 + if let desc = data.descriptions[uuid] { 78 + assets[i].assetDescription = desc 79 + } 80 + if let edit = data.edits[uuid] { 81 + assets[i].editedAt = edit.editedAt 82 + assets[i].editor = edit.editor 83 + } 84 + } 85 + } 86 + } 87 + 88 + // MARK: - SQLite Helpers 89 + 90 + extension PhotosDatabase { 91 + private static func openDatabase(path: String) -> OpaquePointer? { 92 + var db: OpaquePointer? 93 + let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX 94 + guard sqlite3_open_v2(path, &db, flags, nil) == SQLITE_OK else { 95 + if let db { sqlite3_close(db) } 96 + return nil 97 + } 98 + return db 99 + } 100 + 101 + /// Execute a query with resilience — returns empty results if the table 102 + /// doesn't exist (schema varies across macOS versions). 103 + private static func safeQuery( 104 + db: OpaquePointer, 105 + sql: String, 106 + label: String, 107 + handler: (OpaquePointer) -> Void 108 + ) { 109 + var stmt: OpaquePointer? 110 + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { 111 + return 112 + } 113 + defer { sqlite3_finalize(stmt) } 114 + 115 + while sqlite3_step(stmt) == SQLITE_ROW { 116 + handler(stmt!) 117 + } 118 + } 119 + 120 + private static func stringColumn(_ stmt: OpaquePointer, _ index: Int32) -> String? { 121 + guard let cStr = sqlite3_column_text(stmt, index) else { return nil } 122 + return String(cString: cStr) 123 + } 124 + 125 + private static func doubleColumn(_ stmt: OpaquePointer, _ index: Int32) -> Double? { 126 + if sqlite3_column_type(stmt, index) == SQLITE_NULL { return nil } 127 + return sqlite3_column_double(stmt, index) 128 + } 129 + 130 + private static func intColumn(_ stmt: OpaquePointer, _ index: Int32) -> Int { 131 + Int(sqlite3_column_int64(stmt, index)) 132 + } 133 + } 134 + 135 + // MARK: - UUID Map (Z_PK → ZUUID) 136 + 137 + extension PhotosDatabase { 138 + /// Build a map from Z_PK (integer primary key) to ZUUID (string). 139 + /// Enrichment queries return Z_PK references; we need ZUUID for matching 140 + /// against PhotoKit's localIdentifier. 141 + private static func buildUUIDMap(db: OpaquePointer) -> [Int: String] { 142 + var map: [Int: String] = [:] 143 + safeQuery( 144 + db: db, 145 + sql: "SELECT Z_PK, ZUUID FROM ZASSET WHERE ZTRASHEDSTATE = 0", 146 + label: "uuid map" 147 + ) { stmt in 148 + let pk = intColumn(stmt, 0) 149 + if let uuid = stringColumn(stmt, 1) { 150 + map[pk] = uuid 151 + } 152 + } 153 + return map 154 + } 155 + } 156 + 157 + // MARK: - Enrichment Queries 158 + 159 + extension PhotosDatabase { 160 + private static func buildDescriptionMap( 161 + db: OpaquePointer, 162 + uuidMap: [Int: String] 163 + ) -> [String: String] { 164 + var map: [String: String] = [:] 165 + safeQuery( 166 + db: db, 167 + sql: """ 168 + SELECT aa.ZASSET, d.ZLONGDESCRIPTION 169 + FROM ZASSETDESCRIPTION d 170 + JOIN ZADDITIONALASSETATTRIBUTES aa ON d.ZASSETATTRIBUTES = aa.Z_PK 171 + WHERE d.ZLONGDESCRIPTION IS NOT NULL AND d.ZLONGDESCRIPTION != '' 172 + """, 173 + label: "descriptions" 174 + ) { stmt in 175 + let pk = intColumn(stmt, 0) 176 + if let uuid = uuidMap[pk], let desc = stringColumn(stmt, 1) { 177 + map[uuid] = desc 178 + } 179 + } 180 + return map 181 + } 182 + 183 + private static func buildKeywordMap( 184 + db: OpaquePointer, 185 + uuidMap: [Int: String] 186 + ) -> [String: [String]] { 187 + var map: [String: [String]] = [:] 188 + safeQuery( 189 + db: db, 190 + sql: """ 191 + SELECT aa.ZASSET, k.ZTITLE 192 + FROM Z_1KEYWORDS jk 193 + JOIN ZKEYWORD k ON jk.Z_52KEYWORDS = k.Z_PK 194 + JOIN ZADDITIONALASSETATTRIBUTES aa ON jk.Z_1ASSETATTRIBUTES = aa.Z_PK 195 + WHERE k.ZTITLE IS NOT NULL 196 + """, 197 + label: "keywords" 198 + ) { stmt in 199 + let pk = intColumn(stmt, 0) 200 + if let uuid = uuidMap[pk], let title = stringColumn(stmt, 1) { 201 + map[uuid, default: []].append(title) 202 + } 203 + } 204 + return map 205 + } 206 + 207 + private static func buildPeopleMap( 208 + db: OpaquePointer, 209 + uuidMap: [Int: String] 210 + ) -> [String: [PersonInfo]] { 211 + var map: [String: [PersonInfo]] = [:] 212 + var seen: [String: Set<String>] = [:] 213 + safeQuery( 214 + db: db, 215 + sql: """ 216 + SELECT df.ZASSETFORFACE, p.ZPERSONUUID, p.ZDISPLAYNAME 217 + FROM ZDETECTEDFACE df 218 + JOIN ZPERSON p ON df.ZPERSONFORFACE = p.Z_PK 219 + WHERE p.ZDISPLAYNAME IS NOT NULL AND p.ZDISPLAYNAME != '' 220 + AND df.ZHIDDEN = 0 AND df.ZASSETVISIBLE = 1 221 + """, 222 + label: "people" 223 + ) { stmt in 224 + let assetPK = intColumn(stmt, 0) 225 + guard let uuid = uuidMap[assetPK], 226 + let personUUID = stringColumn(stmt, 1), 227 + let displayName = stringColumn(stmt, 2) 228 + else { return } 229 + 230 + if seen[uuid, default: []].contains(personUUID) { return } 231 + seen[uuid, default: []].insert(personUUID) 232 + 233 + map[uuid, default: []].append( 234 + PersonInfo(uuid: personUUID, displayName: displayName) 235 + ) 236 + } 237 + return map 238 + } 239 + 240 + private static func buildEditMap( 241 + db: OpaquePointer, 242 + uuidMap: [Int: String] 243 + ) -> [String: EditInfo] { 244 + // First, build the set of assets that have rendered resources 245 + var renderedAssets: Set<Int> = [] 246 + safeQuery( 247 + db: db, 248 + sql: """ 249 + SELECT DISTINCT ir.ZASSET 250 + FROM ZINTERNALRESOURCE ir 251 + WHERE ir.ZRESOURCETYPE = 1 252 + AND ir.ZTRASHEDSTATE = 0 253 + AND ir.ZVERSION != 0 254 + """, 255 + label: "rendered resources" 256 + ) { stmt in 257 + renderedAssets.insert(intColumn(stmt, 0)) 258 + } 259 + 260 + // Then build edit map, requiring both adjustment AND rendered resource 261 + var map: [String: EditInfo] = [:] 262 + safeQuery( 263 + db: db, 264 + sql: """ 265 + SELECT aa.ZASSET, ua.ZADJUSTMENTTIMESTAMP, ua.ZADJUSTMENTFORMATIDENTIFIER 266 + FROM ZADDITIONALASSETATTRIBUTES aa 267 + JOIN ZUNMANAGEDADJUSTMENT ua ON aa.ZUNMANAGEDADJUSTMENT = ua.Z_PK 268 + WHERE aa.ZUNMANAGEDADJUSTMENT IS NOT NULL 269 + AND ua.ZADJUSTMENTFORMATIDENTIFIER IS NOT NULL 270 + """, 271 + label: "edits" 272 + ) { stmt in 273 + let pk = intColumn(stmt, 0) 274 + guard renderedAssets.contains(pk), 275 + let uuid = uuidMap[pk], 276 + let editor = stringColumn(stmt, 2) 277 + else { return } 278 + 279 + let editedAt: Date? = doubleColumn(stmt, 1).map { timestamp in 280 + Date(timeIntervalSince1970: timestamp + coreDataEpochOffset) 281 + } 282 + map[uuid] = EditInfo(editedAt: editedAt, editor: editor) 283 + } 284 + return map 285 + } 286 + }
+65 -16
Tests/AssetInfoTests.swift
··· 9 9 func createPhotoAsset() { 10 10 let date = Date(timeIntervalSince1970: 1_700_000_000) 11 11 let album = AlbumInfo(identifier: "album-1", title: "Vacation") 12 + let person = PersonInfo(uuid: "person-1", displayName: "Bob") 12 13 let info = AssetInfo( 13 - identifier: "ABC-123", 14 + identifier: "ABC-123/L0/001", 14 15 creationDate: date, 15 16 kind: .photo, 16 17 pixelWidth: 4032, ··· 20 21 isFavorite: true, 21 22 originalFilename: "IMG_0001.HEIC", 22 23 uniformTypeIdentifier: "public.heic", 23 - hasEdit: false, 24 - albums: [album] 24 + hasEdit: true, 25 + albums: [album], 26 + keywords: ["sunset", "beach"], 27 + people: [person], 28 + assetDescription: "A nice photo", 29 + editedAt: date, 30 + editor: "com.apple.photos" 25 31 ) 26 32 27 - #expect(info.identifier == "ABC-123") 33 + #expect(info.identifier == "ABC-123/L0/001") 34 + #expect(info.uuid == "ABC-123") 28 35 #expect(info.creationDate == date) 29 36 #expect(info.kind == .photo) 30 37 #expect(info.pixelWidth == 4032) ··· 34 41 #expect(info.isFavorite == true) 35 42 #expect(info.originalFilename == "IMG_0001.HEIC") 36 43 #expect(info.uniformTypeIdentifier == "public.heic") 37 - #expect(info.hasEdit == false) 44 + #expect(info.hasEdit == true) 38 45 #expect(info.albums.count == 1) 39 46 #expect(info.albums[0].title == "Vacation") 47 + #expect(info.keywords == ["sunset", "beach"]) 48 + #expect(info.people.count == 1) 49 + #expect(info.people[0].displayName == "Bob") 50 + #expect(info.assetDescription == "A nice photo") 51 + #expect(info.editedAt == date) 52 + #expect(info.editor == "com.apple.photos") 40 53 } 41 54 42 - @Test("creates video asset with nil optional fields") 55 + @Test("creates video asset with default enrichment fields") 43 56 func createVideoAssetMinimal() { 44 57 let info = AssetInfo( 45 - identifier: "VID-456", 58 + identifier: "VID-456/L0/001", 46 59 creationDate: nil, 47 60 kind: .video, 48 61 pixelWidth: 1920, ··· 56 69 albums: [] 57 70 ) 58 71 59 - #expect(info.identifier == "VID-456") 60 - #expect(info.creationDate == nil) 72 + #expect(info.uuid == "VID-456") 61 73 #expect(info.kind == .video) 62 - #expect(info.latitude == nil) 63 - #expect(info.originalFilename == nil) 64 - #expect(info.albums.isEmpty) 74 + #expect(info.keywords.isEmpty) 75 + #expect(info.people.isEmpty) 76 + #expect(info.assetDescription == nil) 77 + #expect(info.editedAt == nil) 78 + #expect(info.editor == nil) 79 + } 80 + 81 + @Test("uuid extraction from localIdentifier") 82 + func uuidExtraction() { 83 + let withSuffix = AssetInfo( 84 + identifier: "AAAA-BBBB-CCCC/L0/001", 85 + creationDate: nil, kind: .photo, 86 + pixelWidth: 1, pixelHeight: 1, 87 + latitude: nil, longitude: nil, 88 + isFavorite: false, originalFilename: nil, 89 + uniformTypeIdentifier: nil, hasEdit: false, 90 + albums: [] 91 + ) 92 + #expect(withSuffix.uuid == "AAAA-BBBB-CCCC") 93 + 94 + let withoutSuffix = AssetInfo( 95 + identifier: "PLAIN-UUID", 96 + creationDate: nil, kind: .photo, 97 + pixelWidth: 1, pixelHeight: 1, 98 + latitude: nil, longitude: nil, 99 + isFavorite: false, originalFilename: nil, 100 + uniformTypeIdentifier: nil, hasEdit: false, 101 + albums: [] 102 + ) 103 + #expect(withoutSuffix.uuid == "PLAIN-UUID") 65 104 } 66 105 67 106 @Test("asset kind raw values match CLI constants") ··· 79 118 #expect(a == b) 80 119 #expect(a != c) 81 120 } 121 + 122 + @Test("person info equality") 123 + func personInfoEquality() { 124 + let a = PersonInfo(uuid: "p-1", displayName: "Alice") 125 + let b = PersonInfo(uuid: "p-1", displayName: "Alice") 126 + let c = PersonInfo(uuid: "p-2", displayName: "Bob") 127 + 128 + #expect(a == b) 129 + #expect(a != c) 130 + } 82 131 } 83 132 84 133 @Suite("MockPhotoLibrary Discovery") ··· 87 136 func enumerateAssets() { 88 137 let infos = [ 89 138 AssetInfo( 90 - identifier: "asset-1", 139 + identifier: "asset-1/L0/001", 91 140 creationDate: Date(), 92 141 kind: .photo, 93 142 pixelWidth: 100, ··· 101 150 albums: [] 102 151 ), 103 152 AssetInfo( 104 - identifier: "asset-2", 153 + identifier: "asset-2/L0/001", 105 154 creationDate: nil, 106 155 kind: .video, 107 156 pixelWidth: 1920, ··· 122 171 123 172 let enumerated = library.enumerateAssets() 124 173 #expect(enumerated.count == 2) 125 - #expect(enumerated[0].identifier == "asset-1") 126 - #expect(enumerated[1].identifier == "asset-2") 174 + #expect(enumerated[0].uuid == "asset-1") 175 + #expect(enumerated[1].uuid == "asset-2") 127 176 #expect(enumerated[1].isFavorite == true) 128 177 #expect(enumerated[1].hasEdit == true) 129 178 #expect(enumerated[1].albums.count == 1)
+159
Tests/PhotosDatabaseTests.swift
··· 1 + import Foundation 2 + import SQLite3 3 + import Testing 4 + 5 + @testable import LadderKit 6 + 7 + @Suite("PhotosDatabase") 8 + struct PhotosDatabaseTests { 9 + @Test("enrich applies keywords to matching assets") 10 + func enrichKeywords() { 11 + var assets = [makeAsset(identifier: "ABC-123/L0/001")] 12 + let data = PhotosDatabase.EnrichmentData( 13 + keywords: ["ABC-123": ["sunset", "beach"]], 14 + people: [:], 15 + descriptions: [:], 16 + edits: [:] 17 + ) 18 + 19 + PhotosDatabase.enrich(&assets, with: data) 20 + 21 + #expect(assets[0].keywords == ["sunset", "beach"]) 22 + } 23 + 24 + @Test("enrich applies people to matching assets") 25 + func enrichPeople() { 26 + var assets = [makeAsset(identifier: "DEF-456/L0/001")] 27 + let person = PersonInfo(uuid: "person-1", displayName: "Alice") 28 + let data = PhotosDatabase.EnrichmentData( 29 + keywords: [:], 30 + people: ["DEF-456": [person]], 31 + descriptions: [:], 32 + edits: [:] 33 + ) 34 + 35 + PhotosDatabase.enrich(&assets, with: data) 36 + 37 + #expect(assets[0].people.count == 1) 38 + #expect(assets[0].people[0].displayName == "Alice") 39 + } 40 + 41 + @Test("enrich applies descriptions to matching assets") 42 + func enrichDescriptions() { 43 + var assets = [makeAsset(identifier: "GHI-789/L0/001")] 44 + let data = PhotosDatabase.EnrichmentData( 45 + keywords: [:], 46 + people: [:], 47 + descriptions: ["GHI-789": "A beautiful sunset"], 48 + edits: [:] 49 + ) 50 + 51 + PhotosDatabase.enrich(&assets, with: data) 52 + 53 + #expect(assets[0].assetDescription == "A beautiful sunset") 54 + } 55 + 56 + @Test("enrich applies edit info to matching assets") 57 + func enrichEdits() { 58 + var assets = [makeAsset(identifier: "JKL-012/L0/001")] 59 + let editDate = Date(timeIntervalSince1970: 1_700_000_000) 60 + let data = PhotosDatabase.EnrichmentData( 61 + keywords: [:], 62 + people: [:], 63 + descriptions: [:], 64 + edits: ["JKL-012": .init(editedAt: editDate, editor: "com.apple.photos")] 65 + ) 66 + 67 + PhotosDatabase.enrich(&assets, with: data) 68 + 69 + #expect(assets[0].editedAt == editDate) 70 + #expect(assets[0].editor == "com.apple.photos") 71 + } 72 + 73 + @Test("enrich leaves unmatched assets unchanged") 74 + func enrichNoMatch() { 75 + var assets = [makeAsset(identifier: "NOMATCH/L0/001")] 76 + let data = PhotosDatabase.EnrichmentData( 77 + keywords: ["OTHER": ["tag"]], 78 + people: [:], 79 + descriptions: [:], 80 + edits: [:] 81 + ) 82 + 83 + PhotosDatabase.enrich(&assets, with: data) 84 + 85 + #expect(assets[0].keywords.isEmpty) 86 + #expect(assets[0].people.isEmpty) 87 + #expect(assets[0].assetDescription == nil) 88 + #expect(assets[0].editor == nil) 89 + } 90 + 91 + @Test("enrich handles multiple assets") 92 + func enrichMultiple() { 93 + var assets = [ 94 + makeAsset(identifier: "A/L0/001"), 95 + makeAsset(identifier: "B/L0/001"), 96 + makeAsset(identifier: "C/L0/001"), 97 + ] 98 + let data = PhotosDatabase.EnrichmentData( 99 + keywords: ["A": ["nature"], "C": ["urban"]], 100 + people: [:], 101 + descriptions: ["B": "Photo B"], 102 + edits: [:] 103 + ) 104 + 105 + PhotosDatabase.enrich(&assets, with: data) 106 + 107 + #expect(assets[0].keywords == ["nature"]) 108 + #expect(assets[1].assetDescription == "Photo B") 109 + #expect(assets[2].keywords == ["urban"]) 110 + } 111 + 112 + @Test("readEnrichment returns empty for nonexistent database") 113 + func readEnrichmentMissingDb() { 114 + let data = PhotosDatabase.readEnrichment(dbPath: "/nonexistent/path.sqlite") 115 + #expect(data.keywords.isEmpty) 116 + #expect(data.people.isEmpty) 117 + #expect(data.descriptions.isEmpty) 118 + #expect(data.edits.isEmpty) 119 + } 120 + 121 + @Test("readEnrichment handles empty database") 122 + func readEnrichmentEmptyDb() throws { 123 + let tempDir = FileManager.default.temporaryDirectory 124 + .appendingPathComponent("ladder-db-test-\(UUID().uuidString)") 125 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 126 + defer { try? FileManager.default.removeItem(at: tempDir) } 127 + 128 + let dbPath = tempDir.appendingPathComponent("Photos.sqlite").path 129 + 130 + // Create an empty SQLite database 131 + var db: OpaquePointer? 132 + sqlite3_open(dbPath, &db) 133 + sqlite3_close(db) 134 + 135 + // Should return empty (tables don't exist) without crashing 136 + let data = PhotosDatabase.readEnrichment(dbPath: dbPath) 137 + #expect(data.keywords.isEmpty) 138 + #expect(data.people.isEmpty) 139 + } 140 + 141 + // MARK: - Helpers 142 + 143 + private func makeAsset(identifier: String) -> AssetInfo { 144 + AssetInfo( 145 + identifier: identifier, 146 + creationDate: nil, 147 + kind: .photo, 148 + pixelWidth: 100, 149 + pixelHeight: 100, 150 + latitude: nil, 151 + longitude: nil, 152 + isFavorite: false, 153 + originalFilename: "test.jpg", 154 + uniformTypeIdentifier: "public.jpeg", 155 + hasEdit: false, 156 + albums: [] 157 + ) 158 + } 159 + }