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 // all filenames — used for pure bootstrap mode let unclaimedFilenames: Set // filenames of entries with no localIdentifier // (orphans / pre-ID backups) — filename fallback let totalCount: Int // non-hidden file count for NAS Archive stat /// True only when every entry lacks a localIdentifier (pure bootstrap from NAS listing). var isFilenameOnly: Bool { localIdentifiers.isEmpty } func matches(localIdentifier id: String) -> Bool { !id.isEmpty && localIdentifiers.contains(id) } /// Full filename match — only used in pure bootstrap mode. func matches(filename: String) -> Bool { lowercasedFilenames.contains(filename.lowercased()) } /// Filename match against orphan entries only. /// Use as fallback when localIdentifier matching fails: if a NAS file was uploaded /// in a session where the localIdentifier wasn't saved (interrupted / pre-ID backup), /// matching by filename prevents re-uploading it. func matchesUnclaimed(filename: String) -> Bool { unclaimedFilenames.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 unclaimed = Set() var count = 0 for entry in manifest.entries { if !entry.filename.hasPrefix(".") { count += 1 } let lower = entry.filename.lowercased() names.insert(lower) if !entry.localIdentifier.isEmpty { ids.insert(entry.localIdentifier) } else { unclaimed.insert(lower) } } self.localIdentifiers = ids self.lowercasedFilenames = names self.unclaimedFilenames = unclaimed self.totalCount = count } // Same as init(manifest:) but with a corrected totalCount for display. // Used when the manifest has too few entries to trust but localIdentifiers are still valid. init(manifest: BackupManifest, overrideTotalCount: Int) { var ids = Set(minimumCapacity: manifest.entries.count) var names = Set(minimumCapacity: manifest.entries.count) var unclaimed = Set() for entry in manifest.entries { let lower = entry.filename.lowercased() names.insert(lower) if !entry.localIdentifier.isEmpty { ids.insert(entry.localIdentifier) } else { unclaimed.insert(lower) } } self.localIdentifiers = ids self.lowercasedFilenames = names self.unclaimedFilenames = unclaimed self.totalCount = overrideTotalCount } // 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.unclaimedFilenames = names // all filename-only in bootstrap mode 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, Sendable { 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 } }