4 Commits

Author SHA1 Message Date
Robin Kutesa
2384a74517 fix: use NAS directory filenames for already-safe matching when manifest is sparse
When Kisani's manifest has 0–1 entries but the NAS directory contains thousands of
files uploaded by other tools, reconcile previously showed Already Safe = 1 because
countSafe only matched against the tiny manifest.

The validity check in buildManifestIndex now:
- Stores the filtered NAS filenames in NASManifestCache (directoryFilenames field)
- Returns a ManifestIndex(nasListing:) for filename-based matching against all NAS files

The fast-path reconcile now:
- Detects a sparse manifest (entries ≤ 1, dirCount > 1)
- Rebuilds ManifestIndex(nasFilenames:) from the cached filenames for O(1) filename matching
- Falls back to full NAS reconcile if cached filenames aren't available yet

Adds ManifestIndex.init(nasFilenames:) for fast-path reconstruction from cached strings.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 21:07:00 +03:00
Robin Kutesa
0e65a548f2 fix: NAS operation timeouts, fast-path warm-up, 30-min stale threshold
Three changes that together eliminate the "Checking…" stall and excessive NAS
rescanning without requiring a database migration:

NASManifestCache: extend stale threshold from 5 min to 30 min. directoryCount
is now updated incrementally after every upload, and PHPhotoLibraryChangeObserver
keeps LocalPhotoIndex current — a full NAS roundtrip every 5 min was redundant
and forced the expensive reconcile path far more often than necessary.

BackupStatusService: add withTimeout(_:work:) using a racing ThrowingTaskGroup
so NAS operations (SMB connect, manifest download, directory listing) fail fast
instead of hanging indefinitely when the NAS is slow or unreachable. Timeouts:
15s for connect, 12s for manifest download, 12s for directory listings. On
timeout, BackupError.timeout is caught separately to preserve cached counts
instead of marking connectionState = .offline.

BackupStatusService: call LocalPhotoIndex.shared.loadOrBuild() at the start of
performRefresh before checking localIndexCount. Without this, AppDelegate's
concurrent loadOrBuild() task races with the first onActive() trigger; if it
loses, localIndexCount == 0 and the fast path is bypassed, forcing a full NAS
roundtrip on every cold launch.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 20:41:49 +03:00
Robin Kutesa
0acf111d32 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>
2026-05-18 18:03:56 +03:00
Robin Kutesa
1afcbaff96 perf: eliminate full rescans with local caches; add queue tracking, notifications, SyncView redesign
Cached indexes:
- NASManifestCache: actor-based disk cache for the NAS manifest (kisani_manifest_cache.json)
  Avoids NAS network round-trip when cache < 5 min old. Updated after every manifest download.
- LocalPhotoIndex: actor-based disk cache of phone asset metadata (kisani_local_index.json)
  Built once on first launch, then updated incrementally via PHPhotoLibraryChangeObserver.
  O(1) countSafe via in-memory dict (vs PHFetchResult batch enumeration + CoreData calls).

Fast path reconcile:
  When both caches are warm, reconcile() skips NAS connection + PHFetchResult enumeration
  entirely and returns in microseconds. Falls through to full NAS path only when stale.

AutoBackupCoordinator.onActive():
  Changed force: true → force: false so the 30-second debounce prevents expensive rescans
  on rapid app foreground/background cycles. Dashboard appears instantly from cached snapshot.

BackupQueueItem + queue tracking:
  BackupEngine publishes queueItems: [BackupQueueItem] with per-file status, progress,
  speed, error details. Queue is persisted to disk and reloaded on next launch so SyncView
  always has data to show even after restart.

NotificationService:
  Centralised local notification sender replacing the inline UNMutableNotificationContent
  blocks. Sends for: backup started, completed, failed, paused, NAS offline. Uses category
  identifiers so same-type notifications replace each other.

SyncView redesign:
  - Removed pill/chip around NAS IP — replaced with plain "Online/Offline/Checking" dot+text
  - No live NAS directory listing (was expensive NAS connection on every tab open)
  - Shows engine.queueItems grouped into Active / Failed / Completed sections
  - Per-file rows with inline progress bar + bytes/speed for uploading items, error text for failed
  - Idle state shows archive count from statusService snapshot

GalleryViewModel:
  NAS tab and All tab now use NASManifestCache as fallback when statusService.lastManifest
  is nil (offline launch). Gallery shows NAS items even when NAS is unreachable.

AppDelegate:
  Bootstraps LocalPhotoIndex.loadOrBuild() in background on launch (non-blocking).

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-17 23:28:29 +03:00