import Foundation import Photos // MARK: — ManifestIndex // Lightweight comparison structure — two Sets for O(1) lookups. // Built from BackupManifest and then the manifest is released. // Sendable: all stored properties are value types — safe to pass across task boundaries. struct ManifestIndex: Sendable { let localIdentifiers: Set // PHAsset.localIdentifier (primary key) let lowercasedFilenames: Set // filename fallback for legacy/bootstrap entries let totalCount: Int // non-hidden file count for NAS Archive stat var isFilenameOnly: Bool { localIdentifiers.isEmpty } func matches(localIdentifier id: String) -> Bool { !id.isEmpty && localIdentifiers.contains(id) } func matches(filename: String) -> Bool { lowercasedFilenames.contains(filename.lowercased()) } // Build from decoded manifest — manifest released by caller after this returns init(manifest: BackupManifest) { var ids = Set(minimumCapacity: manifest.entries.count) var names = Set(minimumCapacity: manifest.entries.count) var count = 0 for entry in manifest.entries { if !entry.filename.hasPrefix(".") { count += 1 } if !entry.localIdentifier.isEmpty { ids.insert(entry.localIdentifier) } names.insert(entry.filename.lowercased()) } self.localIdentifiers = ids self.lowercasedFilenames = names self.totalCount = count } // Build from NAS directory listing (bootstrap — no localIdentifiers known) init(nasListing items: [NASItem]) { var names = Set() var count = 0 for item in items where !item.isDirectory && item.name != BackupManifest.remoteFilename { if !item.name.hasPrefix(".") { count += 1 } names.insert(item.name.lowercased()) } self.localIdentifiers = [] self.lowercasedFilenames = names self.totalCount = count } } // MARK: — ManifestEntry struct ManifestEntry: Codable, Sendable { let localIdentifier: String // PHAsset.localIdentifier — primary key let filename: String let creationDate: Date? let fileSize: Int64 let remotePath: String let uploadedAt: Date } struct BackupManifest: Codable { static let remoteFilename = ".kisani.json" var version: Int = 1 var lastUpdated: Date = Date() var entries: [ManifestEntry] = [] // Primary match: stable PHAsset localIdentifier func contains(localIdentifier id: String) -> Bool { guard !id.isEmpty else { return false } return entries.contains { $0.localIdentifier == id } } // Fallback: case-insensitive filename (for assets backed up before manifest existed) func containsByFilename(_ name: String) -> Bool { let lower = name.lowercased() return entries.contains { $0.filename.lowercased() == lower } } // Merge new entries, replacing any existing record for the same localIdentifier mutating func merge(entries newEntries: [ManifestEntry]) { let newIds = Set(newEntries.map { $0.localIdentifier }.filter { !$0.isEmpty }) entries.removeAll { !$0.localIdentifier.isEmpty && newIds.contains($0.localIdentifier) } entries.append(contentsOf: newEntries) lastUpdated = Date() } // Bootstrap from a raw NAS directory listing (no localIdentifiers known) static func buildFromNASListing(_ items: [NASItem]) -> BackupManifest { var manifest = BackupManifest() manifest.entries = items .filter { !$0.isDirectory && $0.name != remoteFilename } .map { item in ManifestEntry( localIdentifier: "", filename: item.name, creationDate: item.modifiedDate, fileSize: item.size, remotePath: item.path, uploadedAt: item.modifiedDate ?? Date() ) } return manifest } }