Root cause: ManifestIndex.isFilenameOnly is false as soon as any single entry has a localIdentifier. All filename-only entries (orphans uploaded in prior sessions without a saved localIdentifier) are never matched, so their assets appear pending on every reconcile and the backup re-starts from scratch on every app open. Fix: add unclaimedFilenames set — filenames of entries with localIdentifier: "". Use this as a targeted fallback in countSafe / pendingIDs / BackupEngine filter: an asset not matched by localIdentifier is still considered safe if its filename matches an unclaimed entry. This is safe because unclaimed entries are specifically files we know are on NAS but whose identity we can only track by name. Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
128 lines
5.0 KiB
Swift
128 lines
5.0 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|