Aug 17 - Update photo dignostic
This commit is contained in:
@@ -0,0 +1,483 @@
|
||||
// Views/Dashboard/PhotoDiagnosticView.swift
|
||||
// ------------------------------------------
|
||||
// READ-ONLY diagnostic for the lost-photo defect (August 2026).
|
||||
//
|
||||
// Reports, per record, which photo fields never received a server path and
|
||||
// whether the underlying JPEG is still recoverable on this device. It exists to
|
||||
// size the problem BEFORE any fix ships, because the fix sequence is
|
||||
// destructive if run in the wrong order: correcting cleanupOrphanedPhotos()
|
||||
// deletes exactly the files this report is looking for.
|
||||
//
|
||||
// THIS VIEW MUST STAY READ-ONLY. It never calls context.save(), context.delete(),
|
||||
// FileManager write/remove, or any APIClient method. It only fetches, reads
|
||||
// files' existence, and formats text. Anything that repairs data belongs in a
|
||||
// separate, explicitly-named screen — a diagnostic the user cannot trust to be
|
||||
// safe is a diagnostic they will not run.
|
||||
//
|
||||
// ── What it looks for ────────────────────────────────────────────────────────
|
||||
// A photo is "lost" when a form field still holds the local sentinel
|
||||
// ("local://<path>") instead of a server path ("uploads/..."). APIClient
|
||||
// .submitInspection() rewrites that sentinel to "" in the request body only —
|
||||
// inspection.formData is left intact — so the device still knows which field
|
||||
// the photo belonged to. That is what makes recovery possible, and what this
|
||||
// report enumerates.
|
||||
//
|
||||
// ── File resolution ──────────────────────────────────────────────────────────
|
||||
// Stored paths are ABSOLUTE and include the app container UUID, which iOS
|
||||
// changes on update / reinstall / restore. So a stored path that no longer
|
||||
// exists does NOT mean the file is gone: the same filename usually still exists
|
||||
// under the current container. Filenames are UUIDs generated per save, so a
|
||||
// basename match is unambiguous and safe to rely on.
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import UIKit
|
||||
|
||||
// MARK: - Model
|
||||
|
||||
/// Where the JPEG actually is, independent of what the database claims.
|
||||
enum PhotoFileState {
|
||||
/// The stored absolute path resolves — nothing has moved.
|
||||
case foundAtStoredPath
|
||||
/// The stored path is stale (container UUID changed) but a file with the
|
||||
/// same unique filename exists now. Recoverable; carries the live path.
|
||||
case foundByFilename(String)
|
||||
/// No file with that name anywhere under the photo directories.
|
||||
case missing
|
||||
|
||||
var isRecoverable: Bool {
|
||||
if case .missing = self { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// How much damage has already been done for one missing photo.
|
||||
enum PhotoLossSeverity: Int {
|
||||
/// Submitted to the server with the field blank — the loss is live, and
|
||||
/// only a PATCH can repair it.
|
||||
case lostOnServer = 0
|
||||
/// Completed but still in the outbox: it will be submitted blank on the
|
||||
/// next sync unless the fix lands first.
|
||||
case willBeLostOnNextSync = 1
|
||||
/// Still a draft — nothing lost yet.
|
||||
case draftNotYetSubmitted = 2
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .lostOnServer: return "LOST ON SERVER"
|
||||
case .willBeLostOnNextSync: return "WILL BE LOST"
|
||||
case .draftNotYetSubmitted: return "DRAFT (safe)"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .lostOnServer: return .red
|
||||
case .willBeLostOnNextSync: return .orange
|
||||
case .draftNotYetSubmitted: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PhotoDiagnosticEntry: Identifiable {
|
||||
let id = UUID()
|
||||
|
||||
let kind: String // "Inspection" | "Issue"
|
||||
let localId: String
|
||||
let serverId: Int? // the row to PATCH, when already submitted
|
||||
let title: String // template / issue description
|
||||
let facilityName: String
|
||||
let date: Date
|
||||
|
||||
let fieldId: String? // nil for issue photos (no form field)
|
||||
let storedPath: String
|
||||
let fileState: PhotoFileState
|
||||
/// PendingPhoto.uploadStatus, or nil when no row survives for this photo.
|
||||
let uploadStatus: String?
|
||||
let severity: PhotoLossSeverity
|
||||
}
|
||||
|
||||
// MARK: - View
|
||||
|
||||
struct PhotoDiagnosticView: View {
|
||||
|
||||
@Environment(\.modelContext) private var context
|
||||
|
||||
@State private var entries: [PhotoDiagnosticEntry] = []
|
||||
@State private var failedPhotoCount = 0
|
||||
@State private var totalPhotoRows = 0
|
||||
@State private var scanned = false
|
||||
@State private var copied = false
|
||||
|
||||
private var lostOnServer: [PhotoDiagnosticEntry] {
|
||||
entries.filter { $0.severity == .lostOnServer }
|
||||
}
|
||||
private var willBeLost: [PhotoDiagnosticEntry] {
|
||||
entries.filter { $0.severity == .willBeLostOnNextSync }
|
||||
}
|
||||
private var drafts: [PhotoDiagnosticEntry] {
|
||||
entries.filter { $0.severity == .draftNotYetSubmitted }
|
||||
}
|
||||
private var recoverable: [PhotoDiagnosticEntry] {
|
||||
entries.filter { $0.fileState.isRecoverable }
|
||||
}
|
||||
private var unrecoverable: [PhotoDiagnosticEntry] {
|
||||
entries.filter { !$0.fileState.isRecoverable }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
Label(
|
||||
"This screen only reads. It does not upload, delete, "
|
||||
+ "repair, or modify anything.",
|
||||
systemImage: "lock.shield"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !scanned {
|
||||
Section {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("Scanning…").padding(.leading, 8)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
summarySection
|
||||
if !entries.isEmpty {
|
||||
entrySection("Lost on Server", lostOnServer,
|
||||
footer: "Submitted with the photo field blank. "
|
||||
+ "Repairing these needs a re-upload plus a PATCH.")
|
||||
entrySection("Will Be Lost on Next Sync", willBeLost,
|
||||
footer: "Still in the outbox. These are submitted "
|
||||
+ "blank unless the fix lands first.")
|
||||
entrySection("Drafts", drafts,
|
||||
footer: "Not submitted yet — nothing lost.")
|
||||
}
|
||||
copySection
|
||||
}
|
||||
}
|
||||
.navigationTitle("Photo Diagnostic")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { runScan() }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
scanned = false
|
||||
runScan()
|
||||
} label: {
|
||||
Label("Rescan", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sections ──────────────────────────────────────────────────────────
|
||||
|
||||
private var summarySection: some View {
|
||||
Section("Summary") {
|
||||
row("Photos affected", "\(entries.count)",
|
||||
tint: entries.isEmpty ? .green : .red)
|
||||
row("Already lost on server", "\(lostOnServer.count)",
|
||||
tint: lostOnServer.isEmpty ? .secondary : .red)
|
||||
row("Will be lost on next sync", "\(willBeLost.count)",
|
||||
tint: willBeLost.isEmpty ? .secondary : .orange)
|
||||
row("Still recoverable (file on device)", "\(recoverable.count)",
|
||||
tint: recoverable.isEmpty ? .secondary : .green)
|
||||
row("File gone — unrecoverable", "\(unrecoverable.count)",
|
||||
tint: unrecoverable.isEmpty ? .secondary : .red)
|
||||
|
||||
Divider()
|
||||
row("Photo upload rows total", "\(totalPhotoRows)", tint: .secondary)
|
||||
row("Rows marked failed", "\(failedPhotoCount)",
|
||||
tint: failedPhotoCount == 0 ? .secondary : .orange)
|
||||
|
||||
if entries.isEmpty {
|
||||
Label("No missing photos found on this device.",
|
||||
systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func entrySection(_ title: String,
|
||||
_ list: [PhotoDiagnosticEntry],
|
||||
footer: String) -> some View {
|
||||
if !list.isEmpty {
|
||||
Section {
|
||||
ForEach(list) { e in entryRow(e) }
|
||||
} header: {
|
||||
Text("\(title) (\(list.count))")
|
||||
} footer: {
|
||||
Text(footer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func entryRow(_ e: PhotoDiagnosticEntry) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(e.severity.label)
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(e.severity.color.opacity(0.15))
|
||||
.foregroundStyle(e.severity.color)
|
||||
.clipShape(Capsule())
|
||||
Spacer()
|
||||
if let sid = e.serverId {
|
||||
Text("server #\(sid)")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("not on server")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text("\(e.kind): \(e.title)").font(.callout.bold())
|
||||
Text(e.facilityName).font(.caption).foregroundStyle(.secondary)
|
||||
Text(e.date.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
|
||||
if let fid = e.fieldId {
|
||||
Text("Field ID: \(fid)").font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Upload status: \(e.uploadStatus ?? "no record")")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
|
||||
switch e.fileState {
|
||||
case .foundAtStoredPath:
|
||||
Label("File present at stored path — recoverable",
|
||||
systemImage: "checkmark.circle")
|
||||
.font(.caption2).foregroundStyle(.green)
|
||||
case .foundByFilename:
|
||||
Label("Stored path stale (container changed) — file found by "
|
||||
+ "name, recoverable", systemImage: "arrow.triangle.2.circlepath")
|
||||
.font(.caption2).foregroundStyle(.orange)
|
||||
case .missing:
|
||||
Label("File not on this device — unrecoverable",
|
||||
systemImage: "exclamationmark.triangle")
|
||||
.font(.caption2).foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private var copySection: some View {
|
||||
Section {
|
||||
Button {
|
||||
UIPasteboard.general.string = textReport()
|
||||
copied = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copied = false }
|
||||
} label: {
|
||||
Label(copied ? "Copied" : "Copy Full Report",
|
||||
systemImage: copied ? "checkmark" : "doc.on.doc")
|
||||
}
|
||||
} footer: {
|
||||
Text("Copies a plain-text version, including full file paths, for "
|
||||
+ "sharing or for driving a recovery pass.")
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ label: String, _ value: String, tint: Color) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
Spacer()
|
||||
Text(value).bold().foregroundStyle(tint)
|
||||
}
|
||||
.font(.callout)
|
||||
}
|
||||
|
||||
// ── Scan (read-only) ──────────────────────────────────────────────────
|
||||
|
||||
private func runScan() {
|
||||
// Fetch-all + filter in Swift — no #Predicate (CLAUDE.md rule 3),
|
||||
// and every `try?` parenthesised before `??` (rule 25).
|
||||
let inspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
|
||||
let issues = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
|
||||
let photos = (try? context.fetch(FetchDescriptor<PendingPhoto>())) ?? []
|
||||
let facilities = (try? context.fetch(FetchDescriptor<LocalFacility>())) ?? []
|
||||
let templates = (try? context.fetch(FetchDescriptor<LocalTemplate>())) ?? []
|
||||
|
||||
totalPhotoRows = photos.count
|
||||
failedPhotoCount = photos.filter { $0.uploadStatus == "failed" }.count
|
||||
|
||||
var facilityName: [Int: String] = [:]
|
||||
for f in facilities { facilityName[f.serverId] = f.name }
|
||||
var templateName: [Int: String] = [:]
|
||||
for t in templates { templateName[t.serverId] = t.name }
|
||||
|
||||
// basename -> live path, for re-resolving stale container paths.
|
||||
let diskIndex = buildDiskIndex()
|
||||
|
||||
// Photo rows keyed by the file they point at, so a form field can be
|
||||
// matched to its upload record.
|
||||
var photoByPath: [String: PendingPhoto] = [:]
|
||||
for p in photos { photoByPath[p.localFilePath] = p }
|
||||
|
||||
var found: [PhotoDiagnosticEntry] = []
|
||||
|
||||
// ── Inspections: form fields still holding the local:// sentinel ──
|
||||
for insp in inspections {
|
||||
for (fieldId, value) in insp.formData {
|
||||
guard let s = value as? String, s.hasPrefix("local://") else { continue }
|
||||
let path = String(s.dropFirst("local://".count))
|
||||
|
||||
let severity: PhotoLossSeverity
|
||||
if insp.status == "draft" {
|
||||
severity = .draftNotYetSubmitted
|
||||
} else if insp.serverId != nil || insp.syncStatus == "synced" {
|
||||
severity = .lostOnServer
|
||||
} else {
|
||||
severity = .willBeLostOnNextSync
|
||||
}
|
||||
|
||||
found.append(PhotoDiagnosticEntry(
|
||||
kind: "Inspection",
|
||||
localId: insp.localId,
|
||||
serverId: insp.serverId,
|
||||
title: templateName[insp.templateServerId]
|
||||
?? "Template #\(insp.templateServerId)",
|
||||
facilityName: facilityName[insp.facilityServerId]
|
||||
?? "Facility #\(insp.facilityServerId)",
|
||||
date: insp.inspectionDate,
|
||||
fieldId: fieldId,
|
||||
storedPath: path,
|
||||
fileState: resolve(path, diskIndex),
|
||||
uploadStatus: photoByPath[path]?.uploadStatus,
|
||||
severity: severity
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Issues: local photos that never produced a server path ────────
|
||||
// Same defect, different surface. processIssueQueue clears
|
||||
// photoLocalPaths only after a successful submit, so a synced issue
|
||||
// still holding local paths with no server paths lost its evidence.
|
||||
for issue in issues {
|
||||
guard !issue.photoLocalPaths.isEmpty else { continue }
|
||||
let missingServerSide = issue.photoServerPaths.count < issue.photoLocalPaths.count
|
||||
guard missingServerSide else { continue }
|
||||
|
||||
let severity: PhotoLossSeverity
|
||||
if issue.syncStatus == "synced" || issue.serverId != nil {
|
||||
severity = .lostOnServer
|
||||
} else if issue.syncStatus == "failed" {
|
||||
severity = .willBeLostOnNextSync
|
||||
} else {
|
||||
severity = .willBeLostOnNextSync
|
||||
}
|
||||
|
||||
for path in issue.photoLocalPaths {
|
||||
// A path already mirrored server-side is fine — skip it.
|
||||
if let p = photoByPath[path], p.uploadStatus == "uploaded" { continue }
|
||||
|
||||
found.append(PhotoDiagnosticEntry(
|
||||
kind: "Issue",
|
||||
localId: issue.localId,
|
||||
serverId: issue.serverId,
|
||||
title: issue.issueDescription.isEmpty
|
||||
? "(no description)"
|
||||
: String(issue.issueDescription.prefix(60)),
|
||||
facilityName: facilityName[issue.facilityServerId]
|
||||
?? "Facility #\(issue.facilityServerId)",
|
||||
date: issue.createdAt,
|
||||
fieldId: nil,
|
||||
storedPath: path,
|
||||
fileState: resolve(path, diskIndex),
|
||||
uploadStatus: photoByPath[path]?.uploadStatus,
|
||||
severity: severity
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
entries = found.sorted {
|
||||
if $0.severity.rawValue != $1.severity.rawValue {
|
||||
return $0.severity.rawValue < $1.severity.rawValue
|
||||
}
|
||||
return $0.date > $1.date
|
||||
}
|
||||
scanned = true
|
||||
}
|
||||
|
||||
/// Map of filename -> current absolute path for every file under the photo
|
||||
/// directories. Filenames are per-save UUIDs, so collisions are not a
|
||||
/// practical concern and a basename match identifies a file uniquely.
|
||||
private func buildDiskIndex() -> [String: String] {
|
||||
let fm = FileManager.default
|
||||
guard let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||
else { return [:] }
|
||||
|
||||
var index: [String: String] = [:]
|
||||
// Both directories the app writes to today. Enumerating rather than
|
||||
// assuming, so a file left by an older build is still found.
|
||||
for sub in ["JQC/Photos", "JQC/ResultPhotos", "JQCPhotos"] {
|
||||
let dir = docs.appendingPathComponent(sub, isDirectory: true)
|
||||
guard let files = try? fm.contentsOfDirectory(
|
||||
at: dir, includingPropertiesForKeys: nil
|
||||
) else { continue }
|
||||
for f in files { index[f.lastPathComponent] = f.path }
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
private func resolve(_ storedPath: String,
|
||||
_ diskIndex: [String: String]) -> PhotoFileState {
|
||||
if FileManager.default.fileExists(atPath: storedPath) {
|
||||
return .foundAtStoredPath
|
||||
}
|
||||
let name = URL(fileURLWithPath: storedPath).lastPathComponent
|
||||
if let live = diskIndex[name] {
|
||||
return .foundByFilename(live)
|
||||
}
|
||||
return .missing
|
||||
}
|
||||
|
||||
// ── Text report ───────────────────────────────────────────────────────
|
||||
|
||||
private func textReport() -> String {
|
||||
var out = """
|
||||
JQC PHOTO DIAGNOSTIC (read-only)
|
||||
Generated: \(Date().formatted(date: .abbreviated, time: .standard))
|
||||
Server: \(ServerConfig.current)
|
||||
|
||||
SUMMARY
|
||||
Photos affected .................. \(entries.count)
|
||||
Already lost on server ......... \(lostOnServer.count)
|
||||
Will be lost on next sync ...... \(willBeLost.count)
|
||||
Drafts (safe) .................. \(drafts.count)
|
||||
Recoverable (file present) ..... \(recoverable.count)
|
||||
Unrecoverable (file gone) ...... \(unrecoverable.count)
|
||||
Photo rows total ............... \(totalPhotoRows)
|
||||
Rows marked failed ............. \(failedPhotoCount)
|
||||
|
||||
DETAIL
|
||||
|
||||
"""
|
||||
for e in entries {
|
||||
let state: String
|
||||
switch e.fileState {
|
||||
case .foundAtStoredPath: state = "file OK at stored path"
|
||||
case .foundByFilename(let p): state = "file found by name -> \(p)"
|
||||
case .missing: state = "FILE MISSING"
|
||||
}
|
||||
out += """
|
||||
[\(e.severity.label)] \(e.kind) \(e.serverId.map { "server #\($0)" } ?? "(unsent)")
|
||||
title : \(e.title)
|
||||
facility : \(e.facilityName)
|
||||
date : \(e.date.formatted(date: .abbreviated, time: .shortened))
|
||||
localId : \(e.localId)
|
||||
fieldId : \(e.fieldId ?? "-")
|
||||
storedPath : \(e.storedPath)
|
||||
upload : \(e.uploadStatus ?? "no record")
|
||||
fileState : \(state)
|
||||
|
||||
"""
|
||||
}
|
||||
if entries.isEmpty { out += "(none)\n" }
|
||||
return out
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user