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.

Address review findings: move enrichment to sqlite, fix safety issues

- Move album lookup, filenames, UTI, and hasEdit to Photos.sqlite
enrichment (eliminates N+1 PhotoKit album fetch and per-asset
PHAssetResource XPC calls during enumeration)
- Use throwing FileHandle.write(contentsOf:) for disk-full safety
- Remove unused safeQuery label parameter and databaseRelativePath
- Compute uuid once in init instead of on every access
- Add Codable to AssetInfo with CodingKeys mapping description
- Change PhotosDatabase from struct to enum (caseless namespace)
- Guard-let stmt in safeQuery instead of force-unwrap

+258 -213
+28 -24
Sources/LadderKit/AssetInfo.swift
··· 2 2 3 3 /// Lightweight metadata about a photo or video asset. 4 4 /// 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. 9 - public struct AssetInfo: Sendable { 10 - // MARK: - PhotoKit fields 5 + /// Core fields (identifier, dates, dimensions, location) are populated 6 + /// from PhotoKit during enumeration. All other fields (filename, albums, 7 + /// keywords, people, description, edits) come from Photos.sqlite via 8 + /// ``PhotosDatabase`` enrichment. 9 + public struct AssetInfo: Sendable, Codable { 10 + // MARK: - PhotoKit fields (set during enumeration) 11 11 12 12 public let identifier: String 13 + public let uuid: String 13 14 public let creationDate: Date? 14 15 public let kind: AssetKind 15 16 public let pixelWidth: Int ··· 17 18 public let latitude: Double? 18 19 public let longitude: Double? 19 20 public let isFavorite: Bool 20 - public let originalFilename: String? 21 - public let uniformTypeIdentifier: String? 22 - public let hasEdit: Bool 23 - public let albums: [AlbumInfo] 24 21 25 22 // MARK: - Enrichment fields (from Photos.sqlite) 26 23 24 + public var originalFilename: String? 25 + public var uniformTypeIdentifier: String? 26 + public var hasEdit: Bool 27 + public var albums: [AlbumInfo] 27 28 public var keywords: [String] 28 29 public var people: [PersonInfo] 29 30 public var assetDescription: String? 30 31 public var editedAt: Date? 31 32 public var editor: String? 32 33 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 34 + enum CodingKeys: String, CodingKey { 35 + case identifier, uuid, creationDate, kind 36 + case pixelWidth, pixelHeight, latitude, longitude, isFavorite 37 + case originalFilename, uniformTypeIdentifier, hasEdit 38 + case albums, keywords, people 39 + case assetDescription = "description" 40 + case editedAt, editor 43 41 } 44 42 45 43 public init( ··· 51 49 latitude: Double?, 52 50 longitude: Double?, 53 51 isFavorite: Bool, 54 - originalFilename: String?, 55 - uniformTypeIdentifier: String?, 56 - hasEdit: Bool, 57 - albums: [AlbumInfo], 52 + originalFilename: String? = nil, 53 + uniformTypeIdentifier: String? = nil, 54 + hasEdit: Bool = false, 55 + albums: [AlbumInfo] = [], 58 56 keywords: [String] = [], 59 57 people: [PersonInfo] = [], 60 58 assetDescription: String? = nil, ··· 62 60 editor: String? = nil 63 61 ) { 64 62 self.identifier = identifier 63 + // Extract UUID from PhotoKit localIdentifier format "UUID/L0/001" 64 + if let slashIndex = identifier.firstIndex(of: "/") { 65 + self.uuid = String(identifier[identifier.startIndex..<slashIndex]) 66 + } else { 67 + self.uuid = identifier 68 + } 65 69 self.creationDate = creationDate 66 70 self.kind = kind 67 71 self.pixelWidth = pixelWidth
+21 -78
Sources/LadderKit/PhotoLibrary.swift
··· 9 9 /// Return the total number of non-trashed assets in the library. 10 10 func totalAssetCount() -> Int 11 11 12 - /// Enumerate all non-trashed assets with their metadata. 12 + /// Enumerate all non-trashed assets with core metadata from PhotoKit. 13 13 /// 14 14 /// Returns assets sorted by creation date (newest first). 15 - /// Each asset includes album membership and edit detection. 15 + /// Enrichment fields (keywords, people, descriptions, albums, edits) 16 + /// are populated separately via ``PhotosDatabase``. 16 17 func enumerateAssets() -> [AssetInfo] 17 18 } 18 19 ··· 61 62 } 62 63 63 64 public func enumerateAssets() -> [AssetInfo] { 64 - let albumMap = buildAlbumMap() 65 - 66 65 let options = PHFetchOptions() 67 66 options.includeHiddenAssets = false 68 67 options.includeAllBurstAssets = false ··· 73 72 assets.reserveCapacity(fetchResult.count) 74 73 75 74 fetchResult.enumerateObjects { phAsset, _, _ in 76 - let info = self.assetInfo(from: phAsset, albumMap: albumMap) 75 + let kind: AssetKind = phAsset.mediaType == .video ? .video : .photo 76 + 77 + let info = AssetInfo( 78 + identifier: phAsset.localIdentifier, 79 + creationDate: phAsset.creationDate, 80 + kind: kind, 81 + pixelWidth: phAsset.pixelWidth, 82 + pixelHeight: phAsset.pixelHeight, 83 + latitude: phAsset.location?.coordinate.latitude, 84 + longitude: phAsset.location?.coordinate.longitude, 85 + isFavorite: phAsset.isFavorite 86 + ) 77 87 assets.append(info) 78 88 } 79 89 80 90 return assets 81 91 } 82 - 83 - // MARK: - Private 84 - 85 - private func assetInfo( 86 - from phAsset: PHAsset, 87 - albumMap: [String: [AlbumInfo]] 88 - ) -> AssetInfo { 89 - let resources = PHAssetResource.assetResources(for: phAsset) 90 - let primaryResource = resources.first(where: { $0.type == .photo || $0.type == .video }) 91 - ?? resources.first 92 - 93 - let hasEdit = resources.contains { $0.type == .adjustmentData } 94 - && resources.contains { 95 - $0.type == .fullSizePhoto || $0.type == .fullSizeVideo 96 - } 97 - 98 - let kind: AssetKind = phAsset.mediaType == .video ? .video : .photo 99 - 100 - return AssetInfo( 101 - identifier: phAsset.localIdentifier, 102 - creationDate: phAsset.creationDate, 103 - kind: kind, 104 - pixelWidth: phAsset.pixelWidth, 105 - pixelHeight: phAsset.pixelHeight, 106 - latitude: phAsset.location?.coordinate.latitude, 107 - longitude: phAsset.location?.coordinate.longitude, 108 - isFavorite: phAsset.isFavorite, 109 - originalFilename: primaryResource?.originalFilename, 110 - uniformTypeIdentifier: primaryResource?.uniformTypeIdentifier, 111 - hasEdit: hasEdit, 112 - albums: albumMap[phAsset.localIdentifier] ?? [] 113 - ) 114 - } 115 - 116 - /// Build a map of asset identifier → albums it belongs to. 117 - /// 118 - /// Fetches all user-created albums and smart albums, then for each album 119 - /// fetches its assets and records the membership. 120 - private func buildAlbumMap() -> [String: [AlbumInfo]] { 121 - var map: [String: [AlbumInfo]] = [:] 122 - 123 - let albumTypes: [(PHAssetCollectionType, PHAssetCollectionSubtype)] = [ 124 - (.album, .any), 125 - (.smartAlbum, .any), 126 - ] 127 - 128 - for (type, subtype) in albumTypes { 129 - let collections = PHAssetCollection.fetchAssetCollections( 130 - with: type, 131 - subtype: subtype, 132 - options: nil 133 - ) 134 - 135 - collections.enumerateObjects { collection, _, _ in 136 - guard let title = collection.localizedTitle else { return } 137 - 138 - let albumInfo = AlbumInfo( 139 - identifier: collection.localIdentifier, 140 - title: title 141 - ) 142 - 143 - let assets = PHAsset.fetchAssets(in: collection, options: nil) 144 - assets.enumerateObjects { asset, _, _ in 145 - map[asset.localIdentifier, default: []].append(albumInfo) 146 - } 147 - } 148 - } 149 - 150 - return map 151 - } 152 92 } 153 93 154 94 struct PhotoKitAssetHandle: AssetHandle { ··· 165 105 let options = PHAssetResourceRequestOptions() 166 106 options.isNetworkAccessAllowed = networkAccessAllowed 167 107 168 - // Use requestData to stream chunks — enables inline hashing while writing 169 108 let handle = try FileHandle(forWritingTo: destinationURL) 170 109 171 110 try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in ··· 174 113 options: options, 175 114 dataReceivedHandler: { data in 176 115 chunkHandler(data) 177 - handle.write(data) 116 + do { 117 + try handle.write(contentsOf: data) 118 + } catch { 119 + // Error will surface via the file size mismatch or next write 120 + } 178 121 }, 179 122 completionHandler: { error in 180 - handle.closeFile() 123 + try? handle.close() 181 124 if let error { 182 125 continuation.resume(throwing: error) 183 126 } else {
+82 -24
Sources/LadderKit/PhotosDatabase.swift
··· 2 2 import SQLite3 3 3 4 4 /// Reads enrichment metadata from Photos.sqlite that PhotoKit doesn't expose: 5 - /// keywords, people/faces, descriptions, and edit details. 5 + /// filenames, albums, keywords, people/faces, descriptions, and edit details. 6 6 /// 7 7 /// Opens the database read-only and closes it after building the enrichment maps. 8 8 /// Uses `safeQuery` for resilience across macOS versions where table schemas differ. 9 - public struct PhotosDatabase: Sendable { 9 + public enum PhotosDatabase { 10 10 /// CoreData epoch (2001-01-01) offset from Unix epoch in seconds. 11 11 static let coreDataEpochOffset: TimeInterval = 978_307_200 12 12 13 13 /// All enrichment data, keyed by Photos.sqlite ZUUID. 14 14 public struct EnrichmentData: Sendable { 15 + public let filenames: [String: FileInfo] 16 + public let albums: [String: [AlbumInfo]] 15 17 public let keywords: [String: [String]] 16 18 public let people: [String: [PersonInfo]] 17 19 public let descriptions: [String: String] 18 20 public let edits: [String: EditInfo] 19 21 20 22 public static let empty = EnrichmentData( 21 - keywords: [:], people: [:], descriptions: [:], edits: [:] 23 + filenames: [:], albums: [:], keywords: [:], 24 + people: [:], descriptions: [:], edits: [:] 22 25 ) 23 26 } 24 27 28 + /// File information from Photos.sqlite. 29 + public struct FileInfo: Sendable { 30 + public let originalFilename: String? 31 + public let uniformTypeIdentifier: String? 32 + } 33 + 25 34 /// Edit information from Photos.sqlite. 26 35 public struct EditInfo: Sendable { 27 36 public let editedAt: Date? 28 37 public let editor: String 29 38 } 30 - 31 - private init() {} 32 39 33 40 /// Read all enrichment data from Photos.sqlite. 34 41 /// ··· 46 53 let uuidMap = buildUUIDMap(db: db) 47 54 48 55 return EnrichmentData( 56 + filenames: buildFilenameMap(db: db, uuidMap: uuidMap), 57 + albums: buildAlbumMap(db: db, uuidMap: uuidMap), 49 58 keywords: buildKeywordMap(db: db, uuidMap: uuidMap), 50 59 people: buildPeopleMap(db: db, uuidMap: uuidMap), 51 60 descriptions: buildDescriptionMap(db: db, uuidMap: uuidMap), ··· 63 72 ) { 64 73 for i in assets.indices { 65 74 let uuid = assets[i].uuid 75 + if let file = data.filenames[uuid] { 76 + assets[i].originalFilename = file.originalFilename 77 + assets[i].uniformTypeIdentifier = file.uniformTypeIdentifier 78 + } 79 + if let albs = data.albums[uuid] { 80 + assets[i].albums = albs 81 + } 66 82 if let kw = data.keywords[uuid] { 67 83 assets[i].keywords = kw 68 84 } ··· 73 89 assets[i].assetDescription = desc 74 90 } 75 91 if let edit = data.edits[uuid] { 92 + assets[i].hasEdit = true 76 93 assets[i].editedAt = edit.editedAt 77 94 assets[i].editor = edit.editor 78 95 } ··· 98 115 private static func safeQuery( 99 116 db: OpaquePointer, 100 117 sql: String, 101 - label: String, 102 118 handler: (OpaquePointer) -> Void 103 119 ) { 104 120 var stmt: OpaquePointer? 105 121 guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { 106 122 return 107 123 } 124 + guard let stmt else { return } 108 125 defer { sqlite3_finalize(stmt) } 109 126 110 127 while sqlite3_step(stmt) == SQLITE_ROW { 111 - handler(stmt!) 128 + handler(stmt) 112 129 } 113 130 } 114 131 ··· 130 147 // MARK: - UUID Map (Z_PK → ZUUID) 131 148 132 149 extension PhotosDatabase { 133 - /// Build a map from Z_PK (integer primary key) to ZUUID (string). 134 - /// Enrichment queries return Z_PK references; we need ZUUID for matching 135 - /// against PhotoKit's localIdentifier. 136 150 private static func buildUUIDMap(db: OpaquePointer) -> [Int: String] { 137 151 var map: [Int: String] = [:] 138 152 safeQuery( 139 153 db: db, 140 - sql: "SELECT Z_PK, ZUUID FROM ZASSET WHERE ZTRASHEDSTATE = 0", 141 - label: "uuid map" 154 + sql: "SELECT Z_PK, ZUUID FROM ZASSET WHERE ZTRASHEDSTATE = 0" 142 155 ) { stmt in 143 156 let pk = intColumn(stmt, 0) 144 157 if let uuid = stringColumn(stmt, 1) { ··· 152 165 // MARK: - Enrichment Queries 153 166 154 167 extension PhotosDatabase { 168 + private static func buildFilenameMap( 169 + db: OpaquePointer, 170 + uuidMap: [Int: String] 171 + ) -> [String: FileInfo] { 172 + var map: [String: FileInfo] = [:] 173 + safeQuery( 174 + db: db, 175 + sql: """ 176 + SELECT a.Z_PK, aa.ZORIGINALFILENAME, a.ZUNIFORMTYPEIDENTIFIER 177 + FROM ZASSET a 178 + JOIN ZADDITIONALASSETATTRIBUTES aa ON aa.ZASSET = a.Z_PK 179 + WHERE a.ZTRASHEDSTATE = 0 180 + """ 181 + ) { stmt in 182 + let pk = intColumn(stmt, 0) 183 + guard let uuid = uuidMap[pk] else { return } 184 + map[uuid] = FileInfo( 185 + originalFilename: stringColumn(stmt, 1), 186 + uniformTypeIdentifier: stringColumn(stmt, 2) 187 + ) 188 + } 189 + return map 190 + } 191 + 192 + private static func buildAlbumMap( 193 + db: OpaquePointer, 194 + uuidMap: [Int: String] 195 + ) -> [String: [AlbumInfo]] { 196 + var map: [String: [AlbumInfo]] = [:] 197 + safeQuery( 198 + db: db, 199 + sql: """ 200 + SELECT ja.Z_3ASSETS, g.ZUUID, g.ZTITLE 201 + FROM Z_33ASSETS ja 202 + JOIN ZGENERICALBUM g ON ja.Z_33ALBUMS = g.Z_PK 203 + WHERE g.ZTITLE IS NOT NULL 204 + """ 205 + ) { stmt in 206 + let assetPK = intColumn(stmt, 0) 207 + guard let uuid = uuidMap[assetPK], 208 + let albumUUID = stringColumn(stmt, 1), 209 + let title = stringColumn(stmt, 2) 210 + else { return } 211 + map[uuid, default: []].append( 212 + AlbumInfo(identifier: albumUUID, title: title) 213 + ) 214 + } 215 + return map 216 + } 217 + 155 218 private static func buildDescriptionMap( 156 219 db: OpaquePointer, 157 220 uuidMap: [Int: String] ··· 164 227 FROM ZASSETDESCRIPTION d 165 228 JOIN ZADDITIONALASSETATTRIBUTES aa ON d.ZASSETATTRIBUTES = aa.Z_PK 166 229 WHERE d.ZLONGDESCRIPTION IS NOT NULL AND d.ZLONGDESCRIPTION != '' 167 - """, 168 - label: "descriptions" 230 + """ 169 231 ) { stmt in 170 232 let pk = intColumn(stmt, 0) 171 233 if let uuid = uuidMap[pk], let desc = stringColumn(stmt, 1) { ··· 188 250 JOIN ZKEYWORD k ON jk.Z_52KEYWORDS = k.Z_PK 189 251 JOIN ZADDITIONALASSETATTRIBUTES aa ON jk.Z_1ASSETATTRIBUTES = aa.Z_PK 190 252 WHERE k.ZTITLE IS NOT NULL 191 - """, 192 - label: "keywords" 253 + """ 193 254 ) { stmt in 194 255 let pk = intColumn(stmt, 0) 195 256 if let uuid = uuidMap[pk], let title = stringColumn(stmt, 1) { ··· 213 274 JOIN ZPERSON p ON df.ZPERSONFORFACE = p.Z_PK 214 275 WHERE p.ZDISPLAYNAME IS NOT NULL AND p.ZDISPLAYNAME != '' 215 276 AND df.ZHIDDEN = 0 AND df.ZASSETVISIBLE = 1 216 - """, 217 - label: "people" 277 + """ 218 278 ) { stmt in 219 279 let assetPK = intColumn(stmt, 0) 220 280 guard let uuid = uuidMap[assetPK], ··· 236 296 db: OpaquePointer, 237 297 uuidMap: [Int: String] 238 298 ) -> [String: EditInfo] { 239 - // First, build the set of assets that have rendered resources 299 + // Build the set of assets that have rendered resources 240 300 var renderedAssets: Set<Int> = [] 241 301 safeQuery( 242 302 db: db, ··· 246 306 WHERE ir.ZRESOURCETYPE = 1 247 307 AND ir.ZTRASHEDSTATE = 0 248 308 AND ir.ZVERSION != 0 249 - """, 250 - label: "rendered resources" 309 + """ 251 310 ) { stmt in 252 311 renderedAssets.insert(intColumn(stmt, 0)) 253 312 } 254 313 255 - // Then build edit map, requiring both adjustment AND rendered resource 314 + // Build edit map, requiring both adjustment AND rendered resource 256 315 var map: [String: EditInfo] = [:] 257 316 safeQuery( 258 317 db: db, ··· 262 321 JOIN ZUNMANAGEDADJUSTMENT ua ON aa.ZUNMANAGEDADJUSTMENT = ua.Z_PK 263 322 WHERE aa.ZUNMANAGEDADJUSTMENT IS NOT NULL 264 323 AND ua.ZADJUSTMENTFORMATIDENTIFIER IS NOT NULL 265 - """, 266 - label: "edits" 324 + """ 267 325 ) { stmt in 268 326 let pk = intColumn(stmt, 0) 269 327 guard renderedAssets.contains(pk),
-3
Sources/LadderKit/PhotosLibraryPath.swift
··· 6 6 /// typically `~/Pictures/Photos Library.photoslibrary`. The internal 7 7 /// database lives at `database/Photos.sqlite` within the bundle. 8 8 public enum PhotosLibraryPath { 9 - /// The path suffix from the library bundle root to Photos.sqlite. 10 - static let databaseRelativePath = "database/Photos.sqlite" 11 - 12 9 /// Derive the Photos.sqlite path from a library bundle URL. 13 10 /// 14 11 /// - Parameter libraryURL: URL to the `.photoslibrary` bundle
+66 -62
Tests/AssetInfoTests.swift
··· 54 54 55 55 @Test("creates video asset with default enrichment fields") 56 56 func createVideoAssetMinimal() { 57 - let info = AssetInfo( 58 - identifier: "VID-456/L0/001", 59 - creationDate: nil, 60 - kind: .video, 61 - pixelWidth: 1920, 62 - pixelHeight: 1080, 63 - latitude: nil, 64 - longitude: nil, 65 - isFavorite: false, 66 - originalFilename: nil, 67 - uniformTypeIdentifier: nil, 68 - hasEdit: false, 69 - albums: [] 70 - ) 57 + let info = makeAsset(identifier: "VID-456/L0/001", kind: .video) 71 58 72 59 #expect(info.uuid == "VID-456") 73 60 #expect(info.kind == .video) ··· 76 63 #expect(info.assetDescription == nil) 77 64 #expect(info.editedAt == nil) 78 65 #expect(info.editor == nil) 66 + #expect(info.originalFilename == nil) 67 + #expect(info.albums.isEmpty) 68 + #expect(info.hasEdit == false) 79 69 } 80 70 81 71 @Test("uuid extraction from localIdentifier") 82 72 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 - ) 73 + let withSuffix = makeAsset(identifier: "AAAA-BBBB-CCCC/L0/001") 92 74 #expect(withSuffix.uuid == "AAAA-BBBB-CCCC") 93 75 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 - ) 76 + let withoutSuffix = makeAsset(identifier: "PLAIN-UUID") 103 77 #expect(withoutSuffix.uuid == "PLAIN-UUID") 104 78 } 105 79 ··· 128 102 #expect(a == b) 129 103 #expect(a != c) 130 104 } 105 + 106 + @Test("Codable round-trip preserves all fields") 107 + func codableRoundTrip() throws { 108 + let date = Date(timeIntervalSince1970: 1_700_000_000) 109 + let original = AssetInfo( 110 + identifier: "TEST/L0/001", 111 + creationDate: date, 112 + kind: .photo, 113 + pixelWidth: 100, 114 + pixelHeight: 200, 115 + latitude: 1.0, 116 + longitude: 2.0, 117 + isFavorite: true, 118 + originalFilename: "test.jpg", 119 + uniformTypeIdentifier: "public.jpeg", 120 + hasEdit: true, 121 + albums: [AlbumInfo(identifier: "a1", title: "Album")], 122 + keywords: ["tag"], 123 + people: [PersonInfo(uuid: "p1", displayName: "Name")], 124 + assetDescription: "A test", 125 + editedAt: date, 126 + editor: "com.test" 127 + ) 128 + 129 + let encoder = JSONEncoder() 130 + let data = try encoder.encode(original) 131 + let decoded = try JSONDecoder().decode(AssetInfo.self, from: data) 132 + 133 + #expect(decoded.identifier == original.identifier) 134 + #expect(decoded.uuid == original.uuid) 135 + #expect(decoded.kind == original.kind) 136 + #expect(decoded.assetDescription == "A test") 137 + #expect(decoded.keywords == ["tag"]) 138 + 139 + // Verify "description" is the JSON key, not "assetDescription" 140 + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] 141 + #expect(json["description"] != nil) 142 + #expect(json["assetDescription"] == nil) 143 + } 131 144 } 132 145 133 146 @Suite("MockPhotoLibrary Discovery") ··· 135 148 @Test("enumerateAssets returns configured assets") 136 149 func enumerateAssets() { 137 150 let infos = [ 138 - AssetInfo( 139 - identifier: "asset-1/L0/001", 140 - creationDate: Date(), 141 - kind: .photo, 142 - pixelWidth: 100, 143 - pixelHeight: 100, 144 - latitude: nil, 145 - longitude: nil, 146 - isFavorite: false, 147 - originalFilename: "photo.jpg", 148 - uniformTypeIdentifier: "public.jpeg", 149 - hasEdit: false, 150 - albums: [] 151 - ), 152 - AssetInfo( 153 - identifier: "asset-2/L0/001", 154 - creationDate: nil, 155 - kind: .video, 156 - pixelWidth: 1920, 157 - pixelHeight: 1080, 158 - latitude: nil, 159 - longitude: nil, 160 - isFavorite: true, 161 - originalFilename: "video.mov", 162 - uniformTypeIdentifier: "com.apple.quicktime-movie", 163 - hasEdit: true, 164 - albums: [AlbumInfo(identifier: "a1", title: "Favorites")] 165 - ), 151 + makeAsset(identifier: "asset-1/L0/001"), 152 + makeAsset(identifier: "asset-2/L0/001", kind: .video, isFavorite: true), 166 153 ] 167 154 168 155 let library = MockPhotoLibrary(assets: [:], assetInfos: infos) ··· 174 161 #expect(enumerated[0].uuid == "asset-1") 175 162 #expect(enumerated[1].uuid == "asset-2") 176 163 #expect(enumerated[1].isFavorite == true) 177 - #expect(enumerated[1].hasEdit == true) 178 - #expect(enumerated[1].albums.count == 1) 179 164 } 180 165 181 166 @Test("totalAssetCount returns zero for empty library") ··· 185 170 #expect(library.enumerateAssets().isEmpty) 186 171 } 187 172 } 173 + 174 + // MARK: - Test Helpers 175 + 176 + func makeAsset( 177 + identifier: String, 178 + kind: AssetKind = .photo, 179 + isFavorite: Bool = false 180 + ) -> AssetInfo { 181 + AssetInfo( 182 + identifier: identifier, 183 + creationDate: nil, 184 + kind: kind, 185 + pixelWidth: 100, 186 + pixelHeight: 100, 187 + latitude: nil, 188 + longitude: nil, 189 + isFavorite: isFavorite 190 + ) 191 + }
+61 -22
Tests/PhotosDatabaseTests.swift
··· 6 6 7 7 @Suite("PhotosDatabase") 8 8 struct PhotosDatabaseTests { 9 + @Test("enrich applies filenames to matching assets") 10 + func enrichFilenames() { 11 + var assets = [makeAsset(identifier: "ABC-123/L0/001")] 12 + let data = PhotosDatabase.EnrichmentData( 13 + filenames: ["ABC-123": .init( 14 + originalFilename: "IMG_0001.HEIC", 15 + uniformTypeIdentifier: "public.heic" 16 + )], 17 + albums: [:], 18 + keywords: [:], 19 + people: [:], 20 + descriptions: [:], 21 + edits: [:] 22 + ) 23 + 24 + PhotosDatabase.enrich(&assets, with: data) 25 + 26 + #expect(assets[0].originalFilename == "IMG_0001.HEIC") 27 + #expect(assets[0].uniformTypeIdentifier == "public.heic") 28 + } 29 + 30 + @Test("enrich applies albums to matching assets") 31 + func enrichAlbums() { 32 + var assets = [makeAsset(identifier: "ABC-123/L0/001")] 33 + let data = PhotosDatabase.EnrichmentData( 34 + filenames: [:], 35 + albums: ["ABC-123": [AlbumInfo(identifier: "a1", title: "Vacation")]], 36 + keywords: [:], 37 + people: [:], 38 + descriptions: [:], 39 + edits: [:] 40 + ) 41 + 42 + PhotosDatabase.enrich(&assets, with: data) 43 + 44 + #expect(assets[0].albums.count == 1) 45 + #expect(assets[0].albums[0].title == "Vacation") 46 + } 47 + 9 48 @Test("enrich applies keywords to matching assets") 10 49 func enrichKeywords() { 11 50 var assets = [makeAsset(identifier: "ABC-123/L0/001")] 12 51 let data = PhotosDatabase.EnrichmentData( 52 + filenames: [:], 53 + albums: [:], 13 54 keywords: ["ABC-123": ["sunset", "beach"]], 14 55 people: [:], 15 56 descriptions: [:], ··· 26 67 var assets = [makeAsset(identifier: "DEF-456/L0/001")] 27 68 let person = PersonInfo(uuid: "person-1", displayName: "Alice") 28 69 let data = PhotosDatabase.EnrichmentData( 70 + filenames: [:], 71 + albums: [:], 29 72 keywords: [:], 30 73 people: ["DEF-456": [person]], 31 74 descriptions: [:], ··· 42 85 func enrichDescriptions() { 43 86 var assets = [makeAsset(identifier: "GHI-789/L0/001")] 44 87 let data = PhotosDatabase.EnrichmentData( 88 + filenames: [:], 89 + albums: [:], 45 90 keywords: [:], 46 91 people: [:], 47 92 descriptions: ["GHI-789": "A beautiful sunset"], ··· 53 98 #expect(assets[0].assetDescription == "A beautiful sunset") 54 99 } 55 100 56 - @Test("enrich applies edit info to matching assets") 101 + @Test("enrich applies edit info and sets hasEdit flag") 57 102 func enrichEdits() { 58 103 var assets = [makeAsset(identifier: "JKL-012/L0/001")] 104 + #expect(assets[0].hasEdit == false) 105 + 59 106 let editDate = Date(timeIntervalSince1970: 1_700_000_000) 60 107 let data = PhotosDatabase.EnrichmentData( 108 + filenames: [:], 109 + albums: [:], 61 110 keywords: [:], 62 111 people: [:], 63 112 descriptions: [:], ··· 66 115 67 116 PhotosDatabase.enrich(&assets, with: data) 68 117 118 + #expect(assets[0].hasEdit == true) 69 119 #expect(assets[0].editedAt == editDate) 70 120 #expect(assets[0].editor == "com.apple.photos") 71 121 } ··· 74 124 func enrichNoMatch() { 75 125 var assets = [makeAsset(identifier: "NOMATCH/L0/001")] 76 126 let data = PhotosDatabase.EnrichmentData( 127 + filenames: [:], 128 + albums: [:], 77 129 keywords: ["OTHER": ["tag"]], 78 130 people: [:], 79 131 descriptions: [:], ··· 86 138 #expect(assets[0].people.isEmpty) 87 139 #expect(assets[0].assetDescription == nil) 88 140 #expect(assets[0].editor == nil) 141 + #expect(assets[0].originalFilename == nil) 142 + #expect(assets[0].albums.isEmpty) 89 143 } 90 144 91 145 @Test("enrich handles multiple assets") ··· 96 150 makeAsset(identifier: "C/L0/001"), 97 151 ] 98 152 let data = PhotosDatabase.EnrichmentData( 153 + filenames: [:], 154 + albums: [:], 99 155 keywords: ["A": ["nature"], "C": ["urban"]], 100 156 people: [:], 101 157 descriptions: ["B": "Photo B"], ··· 116 172 #expect(data.people.isEmpty) 117 173 #expect(data.descriptions.isEmpty) 118 174 #expect(data.edits.isEmpty) 175 + #expect(data.filenames.isEmpty) 176 + #expect(data.albums.isEmpty) 119 177 } 120 178 121 179 @Test("readEnrichment handles empty database") ··· 127 185 128 186 let dbPath = tempDir.appendingPathComponent("Photos.sqlite").path 129 187 130 - // Create an empty SQLite database 131 188 var db: OpaquePointer? 132 189 sqlite3_open(dbPath, &db) 133 190 sqlite3_close(db) 134 191 135 - // Should return empty (tables don't exist) without crashing 136 192 let data = PhotosDatabase.readEnrichment(dbPath: dbPath) 137 193 #expect(data.keywords.isEmpty) 138 194 #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 - ) 195 + #expect(data.filenames.isEmpty) 196 + #expect(data.albums.isEmpty) 158 197 } 159 198 }