06/22 Fix Medium impact items

This commit is contained in:
Nguyen Ngo
2026-06-22 11:40:05 -04:00
parent e68a702bf9
commit 93ee02ea18
6 changed files with 297 additions and 7 deletions
+70
View File
@@ -226,6 +226,11 @@ class SyncManager: ObservableObject {
// Fetch dashboard KPIs best-effort, non-fatal on failure.
await fetchDashboardStats()
// Periodically remove local photo files that are no longer needed.
// Runs at most once per hour to avoid repeated FileManager calls on
// every 60-second sync cycle.
cleanupOrphanedPhotos(context: context)
updatePendingCount(context: context)
lastSyncAt = Date()
}
@@ -512,6 +517,71 @@ class SyncManager: ObservableObject {
// Pending Count
// Orphaned Photo Cleanup
// Removes local JPEG files from Documents/JQCPhotos/ that are no longer
// referenced by any LocalInspection, LocalIssue, or PendingPhoto record.
// Once an inspection or issue is fully synced its local photos are no
// longer needed the server holds the canonical copies. Without this,
// weeks of inspections accumulate hundreds of MBs of orphaned files.
//
// Throttled to once per hour via UserDefaults to avoid redundant
// FileManager enumeration on every 60-second sync cycle.
private static let lastCleanupKey = "jqc.photoCleanup.lastRunAt"
private static let cleanupInterval: TimeInterval = 3600 // 1 hour
private func cleanupOrphanedPhotos(context: ModelContext) {
let last = UserDefaults.standard.object(forKey: Self.lastCleanupKey) as? Date
guard last == nil || Date().timeIntervalSince(last!) >= Self.cleanupInterval else { return }
UserDefaults.standard.set(Date(), forKey: Self.lastCleanupKey)
let fm = FileManager.default
guard let docsDir = fm.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
let photosDir = docsDir.appendingPathComponent("JQCPhotos")
guard let diskFiles = try? fm.contentsOfDirectory(
at: photosDir, includingPropertiesForKeys: nil
) else { return }
// Collect all local paths that are still in use.
var referencedPaths = Set<String>()
// PendingPhoto not yet uploaded
if let pendingPhotos = try? context.fetch(FetchDescriptor<PendingPhoto>()) {
for p in pendingPhotos where p.uploadStatus != "uploaded" {
referencedPaths.insert(p.localFilePath)
}
}
// LocalInspection draft photos (formData values starting with "local://")
if let inspections = try? context.fetch(FetchDescriptor<LocalInspection>()) {
for insp in inspections where insp.status == "draft" {
for val in insp.formData.values {
if let s = val as? String, s.hasPrefix("local://") {
referencedPaths.insert(String(s.dropFirst("local://".count)))
}
}
}
}
// LocalIssue unsync'd issue photos
if let issues = try? context.fetch(FetchDescriptor<LocalIssue>()) {
for issue in issues where issue.syncStatus != "synced" {
for path in issue.photoLocalPaths { referencedPaths.insert(path) }
}
}
// Delete any disk file not in referencedPaths
var deletedCount = 0
for fileURL in diskFiles {
let path = fileURL.path
if !referencedPaths.contains(path) {
try? fm.removeItem(at: fileURL)
deletedCount += 1
}
}
if deletedCount > 0 {
print("[JQC] Sync | cleanupOrphanedPhotos | removed \(deletedCount) file(s)")
}
}
func updatePendingCount(context: ModelContext) {
let inspCount = (try? context.fetch(FetchDescriptor<LocalInspection>()))?
.filter { $0.syncStatus == "pending" }.count ?? 0