06/22 Fix Medium impact items
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user