fix: NAS Archive always shows real directory count, not manifest entry count
- NASManifestCache: add directoryCount field (Int?) stored independently from manifest.entries.count; add setDirectoryCount(_:) and addToDirectoryCount(_:); update(_:) now preserves existing directoryCount so manifest refreshes never reset the real folder count to zero - BackupEngine step 3: switch from "NAS if non-empty, else cache" to max(NAS, cache) by entry count — a 1-entry NAS manifest no longer overwrites a 264-entry cache, preventing history loss and the Already Safe = 1 regression - BackupEngine post-upload: call addToDirectoryCount(uploaded) so NAS Archive increments correctly (7422 → 7423) without requiring a full directory rescan - BackupStatusService.buildManifestIndex: call setDirectoryCount(dirCount) in both the validity-check path (manifest ≤ 1 entry) and the bootstrap path so the real folder count survives across reconcile cycles - BackupStatusService.writeManifest: same max(NAS, cache) base-selection logic to fix Already Safe = 1 after backup ends (merge base was 1-entry NAS instead of 264-entry cache) - BackupStatusService.reconcile (fast + full path): use max(index.totalCount, directoryCount) for nasArchiveTotal so Gallery and stats strip reflect the real NAS folder count, not just Kisani manifest entries Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
@@ -153,15 +153,17 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
}.value
|
||||
let safe = await LocalPhotoIndex.shared.countSafe(against: index, filter: filter)
|
||||
let phoneTotal = await LocalPhotoIndex.shared.count(filter: filter)
|
||||
let dirCount = await NASManifestCache.shared.directoryCount ?? 0
|
||||
let nasTotal = max(index.totalCount, dirCount)
|
||||
var updated = BackupStatusSnapshot()
|
||||
updated.phoneTotal = phoneTotal
|
||||
updated.alreadySafe = min(safe, phoneTotal)
|
||||
updated.nasArchiveTotal = index.totalCount
|
||||
updated.nasArchiveTotal = nasTotal
|
||||
updated.connectionState = .connected
|
||||
updated.lastCheckedAt = Date()
|
||||
snapshot = updated
|
||||
saveSnapshot()
|
||||
log.info("Reconcile (cached): phone=\(phoneTotal) safe=\(safe) NAS=\(index.totalCount) needBackup=\(phoneTotal - min(safe, phoneTotal))")
|
||||
log.info("Reconcile (cached): phone=\(phoneTotal) safe=\(safe) NAS=\(nasTotal) (manifest=\(index.totalCount) dir=\(dirCount))")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -186,13 +188,12 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
log.warning("NAS connect failed: \(reason, privacy: .public) — retaining cached counts")
|
||||
refreshError = reason
|
||||
|
||||
// Restore last-known NAS archive count from manifest cache so the dashboard
|
||||
// Restore last-known NAS archive count so the dashboard
|
||||
// doesn't show 0 when the NAS is temporarily offline.
|
||||
if let cached = await NASManifestCache.shared.manifest {
|
||||
let cachedCount = ManifestIndex(manifest: cached).totalCount
|
||||
snapshot.nasArchiveTotal = max(snapshot.nasArchiveTotal, cachedCount)
|
||||
log.info("NAS offline — restored nasArchiveTotal=\(self.snapshot.nasArchiveTotal) from cache")
|
||||
}
|
||||
let cachedCount = (await NASManifestCache.shared.manifest).map { ManifestIndex(manifest: $0).totalCount } ?? 0
|
||||
let cachedDirCount = await NASManifestCache.shared.directoryCount ?? 0
|
||||
snapshot.nasArchiveTotal = max(snapshot.nasArchiveTotal, cachedCount, cachedDirCount)
|
||||
log.info("NAS offline — restored nasArchiveTotal=\(self.snapshot.nasArchiveTotal) (manifest=\(cachedCount) dir=\(cachedDirCount))")
|
||||
snapshot.phoneTotal = phoneTotal
|
||||
snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal)
|
||||
snapshot.connectionState = .offline
|
||||
@@ -232,14 +233,17 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
log.info("Comparison complete — \(safe)/\(phoneTotal) already safe")
|
||||
|
||||
// ── Step 5: Commit ────────────────────────────────────────────────
|
||||
let dirCount = await NASManifestCache.shared.directoryCount ?? 0
|
||||
let nasTotal = max(index.totalCount, dirCount)
|
||||
var updated = BackupStatusSnapshot()
|
||||
updated.phoneTotal = phoneTotal
|
||||
updated.alreadySafe = min(safe, phoneTotal)
|
||||
updated.nasArchiveTotal = index.totalCount
|
||||
updated.nasArchiveTotal = nasTotal
|
||||
updated.connectionState = .connected
|
||||
updated.lastCheckedAt = Date()
|
||||
snapshot = updated
|
||||
saveSnapshot()
|
||||
log.info("Reconcile (full): phone=\(phoneTotal) safe=\(safe) NAS=\(nasTotal) (manifest=\(index.totalCount) dir=\(dirCount))")
|
||||
|
||||
// Invariant check — alreadySafe + needBackup must always equal phoneTotal
|
||||
let inv = updated.alreadySafe + updated.needBackup
|
||||
@@ -281,6 +285,7 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
}.count
|
||||
log.info("buildManifestIndex: validity — manifest=\(index.totalCount) dir=\(dirCount) — using max")
|
||||
if dirCount > index.totalCount {
|
||||
await NASManifestCache.shared.setDirectoryCount(dirCount)
|
||||
return ManifestIndex(manifest: manifest, overrideTotalCount: dirCount)
|
||||
}
|
||||
}
|
||||
@@ -294,9 +299,11 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
// Bootstrap: list NAS directory and build filename-only index in background.
|
||||
let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? []
|
||||
log.info("buildManifestIndex: bootstrap from \(items.count) items in \(conn.remotePath, privacy: .public)")
|
||||
return await Task.detached(priority: .utility) {
|
||||
let bootstrapIndex = await Task.detached(priority: .utility) {
|
||||
ManifestIndex(nasListing: items)
|
||||
}.value
|
||||
await NASManifestCache.shared.setDirectoryCount(bootstrapIndex.totalCount)
|
||||
return bootstrapIndex
|
||||
}
|
||||
|
||||
/// Enumerates a PHFetchResult in batches using autoreleasepool.
|
||||
@@ -384,13 +391,18 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
let existingData = try? await transfer.downloadData(at: manifestPath)
|
||||
|
||||
// Merge + JSON encode off main actor — can be slow for large manifests.
|
||||
// Use the manifest with more entries as the merge base so history is never lost
|
||||
// if the NAS manifest has fewer entries than the local cache (e.g. 1 vs 264).
|
||||
let result: (data: Data, manifest: BackupManifest)? = await Task.detached(priority: .utility) {
|
||||
let nasManifest = existingData.flatMap { try? JSONDecoder.kisani.decode(BackupManifest.self, from: $0) }
|
||||
let nasCount = nasManifest?.entries.count ?? 0
|
||||
let cacheCount = cacheBase?.entries.count ?? 0
|
||||
|
||||
var manifest: BackupManifest
|
||||
if let data = existingData,
|
||||
let existing = try? JSONDecoder.kisani.decode(BackupManifest.self, from: data) {
|
||||
manifest = existing // NAS is authoritative when readable
|
||||
} else if let base = cacheBase {
|
||||
manifest = base // NAS download failed — preserve history from cache
|
||||
if nasCount > 0, nasCount >= cacheCount {
|
||||
manifest = nasManifest! // NAS at least as current — authoritative
|
||||
} else if cacheCount > 0 {
|
||||
manifest = cacheBase! // Cache has more history — preserve it
|
||||
} else {
|
||||
manifest = BackupManifest()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user