Files
Kisani/Core/Models/BackupManifest.swift
Robin Kutesa 025326f2b2 feat: redesign Gallery as photo timeline with sync state, deduplication, and detail view
- GalleryItem: unified model with phone/nas/synced source, sync state enum
- GalleryViewModel: off-actor PHFetchResult + manifest merge, Phone/NAS/All tab filtering, date section grouping, Combine-driven reactivity
- GalleryView: sticky date section headers, 3-col grid, shimmer placeholders, sync dots (green/orange/blue), video duration badge, search bar, context menu (Share, Save to Photos, Delete from Device)
- GalleryDetailView: full-screen swipeable photo viewer (TabView page mode), pinch-to-zoom, double-tap zoom toggle, bottom metadata sheet with file info and actions
- BackupStatusService: expose lastManifest for gallery deduplication without extra NAS connection
- ThumbnailCache: add decodeOffThread static helper for off-actor JPEG decode
- BackupManifest: Sendable conformance for safe Task.detached capture
- ThumbnailCache: synced items load from PHImageManager (instant) instead of NAS

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 21:30:31 +03:00

108 lines
4.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> // filename fallback for legacy/bootstrap entries
let totalCount: Int // non-hidden file count for NAS Archive stat
var isFilenameOnly: Bool { localIdentifiers.isEmpty }
func matches(localIdentifier id: String) -> Bool {
!id.isEmpty && localIdentifiers.contains(id)
}
func matches(filename: String) -> Bool {
lowercasedFilenames.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 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())
}
self.localIdentifiers = ids
self.lowercasedFilenames = names
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.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
}
}