diff --git a/Core/Models/BackupManifest.swift b/Core/Models/BackupManifest.swift index 7bf5cfd..fb0876d 100644 --- a/Core/Models/BackupManifest.swift +++ b/Core/Models/BackupManifest.swift @@ -7,31 +7,50 @@ import Photos // 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 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 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 } - if !entry.localIdentifier.isEmpty { ids.insert(entry.localIdentifier) } - names.insert(entry.filename.lowercased()) + 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 } @@ -45,6 +64,7 @@ struct ManifestIndex: Sendable { } self.localIdentifiers = [] self.lowercasedFilenames = names + self.unclaimedFilenames = names // all filename-only in bootstrap mode self.totalCount = count } } diff --git a/Services/BackupEngine.swift b/Services/BackupEngine.swift index d89784e..ae0625f 100644 --- a/Services/BackupEngine.swift +++ b/Services/BackupEngine.swift @@ -160,7 +160,8 @@ final class BackupEngine: ObservableObject { let pendingAssets: [PhotoAsset] = await Task.detached(priority: .userInitiated) { assets.filter { asset in !currentIndex.matches(localIdentifier: asset.localIdentifier) && - !(currentIndex.isFilenameOnly && currentIndex.matches(filename: asset.filename)) + !(currentIndex.isFilenameOnly && currentIndex.matches(filename: asset.filename)) && + !currentIndex.matchesUnclaimed(filename: asset.filename) } }.value signposter.endInterval("BuildPendingQueue", spFilter, "\(pendingAssets.count) pending") diff --git a/Services/BackupStatusService.swift b/Services/BackupStatusService.swift index ddda135..dfc9bc8 100644 --- a/Services/BackupStatusService.swift +++ b/Services/BackupStatusService.swift @@ -282,11 +282,14 @@ final class BackupStatusService: NSObject, ObservableObject { let asset = result.object(at: i) if index.matches(localIdentifier: asset.localIdentifier) { count += 1 - } else if index.isFilenameOnly { + } else { let resources = PHAssetResource.assetResources(for: asset) - if let name = resources.first?.originalFilename, - index.matches(filename: name) { - count += 1 + if let name = resources.first?.originalFilename { + if index.isFilenameOnly && index.matches(filename: name) { + count += 1 + } else if index.matchesUnclaimed(filename: name) { + count += 1 + } } } } diff --git a/Services/LocalPhotoIndex.swift b/Services/LocalPhotoIndex.swift index d79aa3f..c4be159 100644 --- a/Services/LocalPhotoIndex.swift +++ b/Services/LocalPhotoIndex.swift @@ -59,6 +59,11 @@ actor LocalPhotoIndex { if index.matches(localIdentifier: record.localIdentifier) { safe += 1 } else if index.isFilenameOnly && index.matches(filename: record.filename) { + // Pure bootstrap mode: every entry is filename-only, match by name. + safe += 1 + } else if index.matchesUnclaimed(filename: record.filename) { + // Mixed mode: this specific entry was uploaded without a localIdentifier + // (interrupted session / pre-ID backup). Filename match prevents re-upload. safe += 1 } } @@ -71,7 +76,8 @@ actor LocalPhotoIndex { guard passes(record, filter: filter) else { return nil } let alreadyBacked = index.matches(localIdentifier: record.localIdentifier) || - (index.isFilenameOnly && index.matches(filename: record.filename)) + (index.isFilenameOnly && index.matches(filename: record.filename)) || + index.matchesUnclaimed(filename: record.filename) return alreadyBacked ? nil : record.localIdentifier } }