fix: always refresh NAS dir count, show connection status, fix screenshot filter reactivity

- buildManifestIndex now always scans the NAS directory on every full reconcile
  (removed the `totalCount <= 1` guard) so NAS Archive count updates accurately
  on every manual refresh, not just when the manifest is new/sparse
- The dir > manifest check still switches to filename-based matching when the
  directory has more files than Kisani's manifest (uploaded by other tools)
- checkStatusRow shows NAS connection status (orange dot + "NAS Connected" /
  red dot + "NAS Offline") below the timestamp line
- BackupFilter now conforms to Equatable; BackupView re-reconciles immediately
  when the screenshot/video/RAW filter toggles change

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-18 23:15:51 +03:00
parent 78a6b377f2
commit 6c38893374
3 changed files with 74 additions and 47 deletions

View File

@@ -20,7 +20,7 @@ protocol PhotoLibraryProtocol: AnyObject {
func exportAsset(_ asset: PhotoAsset) async throws -> URL func exportAsset(_ asset: PhotoAsset) async throws -> URL
} }
struct BackupFilter: Codable, Sendable { struct BackupFilter: Codable, Sendable, Equatable {
var includePhotos: Bool = true var includePhotos: Bool = true
var includeVideos: Bool = true var includeVideos: Bool = true
var includeScreenshots: Bool = false var includeScreenshots: Bool = false

View File

@@ -149,6 +149,9 @@ struct BackupView: View {
.onChange(of: store.savedConnection?.remotePath) { _ in .onChange(of: store.savedConnection?.remotePath) { _ in
statusService.refresh(force: true) statusService.refresh(force: true)
} }
.onChange(of: store.backupFilter) { _ in
statusService.refresh(force: true)
}
.onChange(of: engine.job.status) { status in .onChange(of: engine.job.status) { status in
if status == .completed { if status == .completed {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true } withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true }
@@ -492,6 +495,7 @@ struct BackupView: View {
} }
private var checkStatusRow: some View { private var checkStatusRow: some View {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 5) { HStack(spacing: 5) {
if statusService.isRefreshing { if statusService.isRefreshing {
ProgressView() ProgressView()
@@ -523,6 +527,28 @@ struct BackupView: View {
} }
} }
.frame(height: 16) .frame(height: 16)
if statusService.snapshot.connectionState != .unknown {
HStack(spacing: 4) {
Circle()
.fill(nasConnectionStatusColor)
.frame(width: 5, height: 5)
Text(nasConnectionStatusLabel)
.font(.system(size: 9, weight: .regular))
.foregroundStyle(nasConnectionStatusColor)
}
}
}
}
private var nasConnectionStatusColor: Color {
statusService.snapshot.connectionState == .offline
? AppTheme.destructive
: Color(red: 1.0, green: 0.58, blue: 0.0)
}
private var nasConnectionStatusLabel: String {
statusService.snapshot.connectionState == .offline ? "NAS Offline" : "NAS Connected"
} }
private func compactStat(_ value: String, label: String, color: Color) -> some View { private func compactStat(_ value: String, label: String, color: Color) -> some View {

View File

@@ -308,26 +308,27 @@ final class BackupStatusService: NSObject, ObservableObject {
lastManifest = manifest lastManifest = manifest
Task { await NASManifestCache.shared.update(manifest) } Task { await NASManifestCache.shared.update(manifest) }
// Validity check: if the manifest has 1 entries it may be corrupt or newly // Always scan directory so the NAS Archive count is accurate on every
// created. Scan the directory to get the real NAS contents. When the directory // full reconcile (not just when the manifest is new/sparse).
// is larger than the manifest, return a filename-based index so countSafe // When the directory has more files than the manifest (uploaded by other
// matches against the actual NAS files, not just the sparse manifest entries. // tools), switch to filename-based matching so countSafe reflects real
if index.totalCount <= 1 { // NAS coverage instead of only Kisani-tracked files.
let items = (try? await Self.withTimeout(12) { try await transfer.listDirectory(at: conn.remotePath) }) ?? [] let items = (try? await Self.withTimeout(12) { try await transfer.listDirectory(at: conn.remotePath) }) ?? []
let filteredItems = items.filter { let filteredItems = items.filter {
!$0.isDirectory && !$0.name.hasPrefix(".") && $0.name != BackupManifest.remoteFilename !$0.isDirectory && !$0.name.hasPrefix(".") && $0.name != BackupManifest.remoteFilename
} }
let dirCount = filteredItems.count let dirCount = filteredItems.count
log.info("buildManifestIndex: validity — manifest=\(index.totalCount) dir=\(dirCount) — filename matching") log.info("buildManifestIndex: dir=\(dirCount) manifest=\(index.totalCount)")
if dirCount > index.totalCount { if dirCount > index.totalCount {
await NASManifestCache.shared.setDirectoryFilenames(filteredItems.map { $0.name }) await NASManifestCache.shared.setDirectoryFilenames(filteredItems.map { $0.name })
return await Task.detached(priority: .utility) { return await Task.detached(priority: .utility) {
ManifestIndex(nasListing: items) ManifestIndex(nasListing: items)
}.value }.value
} } else {
} await NASManifestCache.shared.setDirectoryCount(dirCount)
return index return index
} }
}
log.warning("buildManifestIndex: manifest downloaded but failed to decode (\(data.count) bytes)") log.warning("buildManifestIndex: manifest downloaded but failed to decode (\(data.count) bytes)")
} catch { } catch {
log.info("buildManifestIndex: manifest not found at \(path, privacy: .public)\(error.localizedDescription, privacy: .public) — falling back to directory listing") log.info("buildManifestIndex: manifest not found at \(path, privacy: .public)\(error.localizedDescription, privacy: .public) — falling back to directory listing")