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 library path validation and remove default db path

Users must select their Photos library explicitly (via NSOpenPanel
in the app), which also grants file access via security-scoped
bookmarks — avoiding Full Disk Access. PhotosLibraryPath validates
the bundle and derives the database path.

+143 -8
+3 -8
Sources/LadderKit/PhotosDatabase.swift
··· 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 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 10 /// CoreData epoch (2001-01-01) offset from Unix epoch in seconds. 17 11 static let coreDataEpochOffset: TimeInterval = 978_307_200 18 12 ··· 38 32 39 33 /// Read all enrichment data from Photos.sqlite. 40 34 /// 41 - /// Opens the database read-only, runs enrichment queries, and closes it. 35 + /// Use ``PhotosLibraryPath/databasePath(for:)`` to derive the `dbPath` 36 + /// from a library bundle URL selected by the user. 42 37 /// Returns `.empty` if the database cannot be opened. 43 38 public static func readEnrichment( 44 - dbPath: String = defaultPath 39 + dbPath: String 45 40 ) -> EnrichmentData { 46 41 guard let db = openDatabase(path: dbPath) else { 47 42 return .empty
+64
Sources/LadderKit/PhotosLibraryPath.swift
··· 1 + import Foundation 2 + 3 + /// Utilities for working with Photos library bundle paths. 4 + /// 5 + /// A Photos library is a bundle (directory) at a user-chosen location, 6 + /// typically `~/Pictures/Photos Library.photoslibrary`. The internal 7 + /// database lives at `database/Photos.sqlite` within the bundle. 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 + /// Derive the Photos.sqlite path from a library bundle URL. 13 + /// 14 + /// - Parameter libraryURL: URL to the `.photoslibrary` bundle 15 + /// (e.g., from NSOpenPanel or a saved bookmark). 16 + /// - Returns: The full path to Photos.sqlite, or `nil` if the 17 + /// library doesn't contain the expected database file. 18 + public static func databasePath(for libraryURL: URL) -> String? { 19 + let dbURL = libraryURL 20 + .appendingPathComponent("database") 21 + .appendingPathComponent("Photos.sqlite") 22 + let path = dbURL.path 23 + 24 + guard FileManager.default.fileExists(atPath: path) else { 25 + return nil 26 + } 27 + return path 28 + } 29 + 30 + /// Validate that a URL points to a Photos library bundle. 31 + /// 32 + /// Checks that: 33 + /// - The URL has a `.photoslibrary` extension 34 + /// - The directory exists 35 + /// - It contains `database/Photos.sqlite` 36 + public static func validate(_ url: URL) -> ValidationResult { 37 + guard url.pathExtension == "photoslibrary" else { 38 + return .invalid("Not a Photos library (expected .photoslibrary extension)") 39 + } 40 + 41 + var isDir: ObjCBool = false 42 + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), 43 + isDir.boolValue 44 + else { 45 + return .invalid("Photos library not found at \(url.path)") 46 + } 47 + 48 + guard databasePath(for: url) != nil else { 49 + return .invalid("Photos library does not contain a database") 50 + } 51 + 52 + return .valid 53 + } 54 + 55 + public enum ValidationResult: Equatable { 56 + case valid 57 + case invalid(String) 58 + 59 + public var isValid: Bool { 60 + if case .valid = self { return true } 61 + return false 62 + } 63 + } 64 + }
+76
Tests/PhotosLibraryPathTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import LadderKit 5 + 6 + @Suite("PhotosLibraryPath") 7 + struct PhotosLibraryPathTests { 8 + @Test("databasePath returns path when database exists") 9 + func databasePathExists() throws { 10 + let tempDir = FileManager.default.temporaryDirectory 11 + .appendingPathComponent("test-\(UUID().uuidString).photoslibrary") 12 + let dbDir = tempDir.appendingPathComponent("database") 13 + try FileManager.default.createDirectory(at: dbDir, withIntermediateDirectories: true) 14 + defer { try? FileManager.default.removeItem(at: tempDir) } 15 + 16 + let dbFile = dbDir.appendingPathComponent("Photos.sqlite") 17 + FileManager.default.createFile(atPath: dbFile.path, contents: Data()) 18 + 19 + let result = PhotosLibraryPath.databasePath(for: tempDir) 20 + #expect(result == dbFile.path) 21 + } 22 + 23 + @Test("databasePath returns nil when database missing") 24 + func databasePathMissing() throws { 25 + let tempDir = FileManager.default.temporaryDirectory 26 + .appendingPathComponent("test-\(UUID().uuidString).photoslibrary") 27 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 28 + defer { try? FileManager.default.removeItem(at: tempDir) } 29 + 30 + let result = PhotosLibraryPath.databasePath(for: tempDir) 31 + #expect(result == nil) 32 + } 33 + 34 + @Test("validate accepts valid library bundle") 35 + func validateValid() throws { 36 + let tempDir = FileManager.default.temporaryDirectory 37 + .appendingPathComponent("test-\(UUID().uuidString).photoslibrary") 38 + let dbDir = tempDir.appendingPathComponent("database") 39 + try FileManager.default.createDirectory(at: dbDir, withIntermediateDirectories: true) 40 + defer { try? FileManager.default.removeItem(at: tempDir) } 41 + 42 + FileManager.default.createFile( 43 + atPath: dbDir.appendingPathComponent("Photos.sqlite").path, 44 + contents: Data() 45 + ) 46 + 47 + let result = PhotosLibraryPath.validate(tempDir) 48 + #expect(result == .valid) 49 + #expect(result.isValid) 50 + } 51 + 52 + @Test("validate rejects wrong extension") 53 + func validateWrongExtension() { 54 + let url = URL(fileURLWithPath: "/tmp/not-a-library.app") 55 + let result = PhotosLibraryPath.validate(url) 56 + #expect(!result.isValid) 57 + } 58 + 59 + @Test("validate rejects nonexistent path") 60 + func validateNonexistent() { 61 + let url = URL(fileURLWithPath: "/nonexistent/My Photos.photoslibrary") 62 + let result = PhotosLibraryPath.validate(url) 63 + #expect(!result.isValid) 64 + } 65 + 66 + @Test("validate rejects library without database") 67 + func validateNoDatabase() throws { 68 + let tempDir = FileManager.default.temporaryDirectory 69 + .appendingPathComponent("test-\(UUID().uuidString).photoslibrary") 70 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 71 + defer { try? FileManager.default.removeItem(at: tempDir) } 72 + 73 + let result = PhotosLibraryPath.validate(tempDir) 74 + #expect(!result.isValid) 75 + } 76 + }