Files
Kisani/Core/Models/BackupManifest.swift
Robin Kutesa 6ae567d008 Fix Already Safe = 0: hybrid ManifestIndex preserves localIdentifiers + NAS filenames
The root cause: buildManifestIndex was discarding manifest localIdentifiers and
switching to pure filename-only matching whenever dirCount > manifest.totalCount
(which is always true when the NAS has files from other tools). If filenames didn't
match exactly, Already Safe dropped to 0 on every stale-cache reconcile.

Fix: introduce ManifestIndex(manifest:nasFilenames:totalCount:) — a hybrid that keeps
Kisani's localIdentifiers as primary keys AND adds all NAS directory filenames for
filename-fallback. Used in:
- buildManifestIndex (full reconcile)
- fast path (when dirCount > cached.entries.count, previously gated at <= 1)
- BackupEngine step 3b (so "Back up again" only uploads genuinely missing files)

Also simplify countSafe / pendingIDs / step-4 filter to always try filename fallback
(removes the isFilenameOnly gate that blocked matches when the index had IDs).

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-19 00:32:03 +03:00

182 lines
7.4 KiB
Swift

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<String> // PHAsset.localIdentifier (primary key)
let lowercasedFilenames: Set<String> // all filenames used for pure bootstrap mode
let unclaimedFilenames: Set<String> // 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<String>(minimumCapacity: manifest.entries.count)
var names = Set<String>(minimumCapacity: manifest.entries.count)
var unclaimed = Set<String>()
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<String>(minimumCapacity: manifest.entries.count)
var names = Set<String>(minimumCapacity: manifest.entries.count)
var unclaimed = Set<String>()
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
}
// Rebuild from cached NAS filenames (fast-path for sparse-manifest reconcile).
// Filenames are expected to already be filtered (no dirs, no hidden, no manifest file).
init(nasFilenames filenames: [String]) {
let names = Set(filenames.map { $0.lowercased() })
self.localIdentifiers = []
self.lowercasedFilenames = names
self.unclaimedFilenames = names
self.totalCount = names.count
}
// Hybrid: manifest localIdentifiers (Kisani primary key) + all NAS filenames
// for filename-fallback matching when the NAS directory has more files than the
// manifest tracks (uploaded by other tools). totalCount = actual NAS file count.
init(manifest: BackupManifest, nasFilenames: [String], totalCount: Int) {
var ids = Set<String>(minimumCapacity: manifest.entries.count)
var names = Set<String>(minimumCapacity: manifest.entries.count + nasFilenames.count)
var unclaimed = Set<String>()
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)
}
}
for name in nasFilenames { names.insert(name.lowercased()) }
self.localIdentifiers = ids
self.lowercasedFilenames = names
self.unclaimedFilenames = unclaimed
self.totalCount = totalCount
}
// Build from NAS directory listing (bootstrap no localIdentifiers known)
init(nasListing items: [NASItem]) {
var names = Set<String>()
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
}
}