← Back to index

SpotEntry

Spots
CodeWhat It DoesHow It Does It
▶ IMPORTS
import Foundation import Combine import CoreLocation import ImageIO import MapKit import UIKitFramework importsImports Foundation, Combine, CoreLocation, ImageIO, MapKit, UIKit.
▶ ENTRY MODE (FOOD / SHOW / PLACE)
enum EntryMode: String, Codable { case food, show, place, shop, event }`EntryMode` enumDefines the `EntryMode` enum. Conforms to String, Codable.
struct SpotEntry: Identifiable, Codable, Equatable { var id = UUID() var playName: String var dateTime: Date var cuisine: String var entryMode: EntryMode = .food var address: String var phone: String = "" var website: String = "" var neighborhood: String = "" var hours: String = "" var confirmationLink: String /// Filename (not full path) of the image stored in the EntryImages folder. /// Nil until an image is first saved. Replaces inline imageData in JSON. var imageFilename: String? /// In-memory image blob — NOT serialised to JSON; populated by EntryStore after load. var imageData: Data? var remindersEnabled: Bool = false var notes: String = "" var rating: Int = 0 // 0 = unrated, 1–5 circles (priority) var beenThere: Bool = false var starRating: Int = 0 // 0 = unrated, 1–5 stars (post-visit rating) /// False until the user (or Claude via email/PDF extraction) explicitly sets a visit date. /// URL-imported restaurants start with false so the date isn't shown in the list. var hasCustomDate: Bool = false /// Geocoded coordinate — stored so we never need to re-geocode. var latitude: Double? = nil var longitude: Double? = nil /// Show entries only: true = "Consider" (no firm date), false = "Upcoming" (confirmed date). var consider: Bool = false /// Show entries only: URL of the show's page on the Internet Broadway Database. var ibdb: String = "" /// Show entries only: URL of the show's page on spectra.theater. var spectra: String = "" /// Food entries only: Yelp search URL for this restaurant. var yelp: String = "" /// Food entries only: Eater NY search URL for this restaurant. var eaterNY: String = "" /// Food entries only: The Infatuation search URL for this restaurant. var infatuation: String = "" /// Food entries only: Resy or OpenTable reservation URL (auto-detected on save). var reservation: String = "" init(id: UUID = UUID(), playName: String, dateTime: Date, cuisine: String, address: String, phone: String = "", website: String = "", neighborhood: String = "", hours: String = "", confirmationLink: String, imageData: Data? = nil, imageFilename: String? = nil, remindersEnabled: Bool = false, notes: String = "", rating: Int = 0, beenThere: Bool = false, starRating: Int = 0, hasCustomDate: Bool = false, latitude: Double? = nil, longitude: Double? = nil, entryMode: EntryMode = .food, consider: Bool = false, ibdb: String = "", spectra: String = "", yelp: String = "", eaterNY: String = "", infatuation: String = "") { self.id = id self.playName = playName self.dateTime = dateTime self.cuisine = cuisine self.address = address self.phone = phone self.website = website self.neighborhood = neighborhood self.hours = hours self.confirmationLink = Self.fixMessageLink(confirmationLink) self.imageData = imageData // Pre-assign a filename so save() can locate the file immediately; // caller may also supply an existing filename directly (e.g. migration). self.imageFilename = imageData != nil ? "\(id.uuidString).jpg" : imageFilename self.remindersEnabled = remindersEnabled self.notes = notes self.rating = rating self.beenThere = beenThere self.starRating = starRating self.hasCustomDate = hasCustomDate self.latitude = latitude self.longitude = longitude self.entryMode = entryMode self.consider = consider self.ibdb = ibdb self.spectra = spectra self.yelp = yelp self.eaterNY = eaterNY self.infatuation = infatuation } // MARK: - Codable private enum CodingKeys: String, CodingKey { case id, playName, dateTime, cuisine, address, phone, website, neighborhood, hours, confirmationLink, imageFilename, // Legacy key — read-only for migration; never written. imageData, remindersEnabled, notes, rating, beenThere, starRating, hasCustomDate, latitude, longitude, entryMode, consider, ibdb, spectra, yelp, eaterNY, infatuation, reservation } /// Custom decoder: reads new `imageFilename` field AND old inline `imageData` /// (migration path — the inline blob is held in `imageData` temporarily so /// EntryStore can write it to disk on the next save). init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(UUID.self, forKey: .id) playName = (try container.decode(String.self, forKey: .playName)) .precomposedStringWithCompatibilityMapping .replacingOccurrences(of: "*", with: "") .trimmingCharacters(in: .whitespacesAndNewlines) dateTime = try container.decode(Date.self, forKey: .dateTime) cuisine = try container.decode(String.self, forKey: .cuisine) address = try container.decode(String.self, forKey: .address) phone = try container.decodeIfPresent(String.self, forKey: .phone) ?? "" website = try container.decodeIfPresent(String.self, forKey: .website) ?? "" neighborhood = try container.decodeIfPresent(String.self, forKey: .neighborhood) ?? "" hours = try container.decodeIfPresent(String.self, forKey: .hours) ?? "" let rawLink = try container.decode(String.self, forKey: .confirmationLink) confirmationLink = Self.fixMessageLink(rawLink) imageFilename = try container.decodeIfPresent(String.self, forKey: .imageFilename) // Read legacy inline blob only when migrating (no filename yet). if imageFilename == nil { imageData = try container.decodeIfPresent(Data.self, forKey: .imageData) } remindersEnabled = try container.decodeIfPresent(Bool.self, forKey: .remindersEnabled) ?? false notes = try container.decodeIfPresent(String.self, forKey: .notes) ?? "" rating = try container.decodeIfPresent(Int.self, forKey: .rating) ?? 0 beenThere = try container.decodeIfPresent(Bool.self, forKey: .beenThere) ?? false starRating = try container.decodeIfPresent(Int.self, forKey: .starRating) ?? 0 hasCustomDate = try container.decodeIfPresent(Bool.self, forKey: .hasCustomDate) ?? false latitude = try container.decodeIfPresent(Double.self, forKey: .latitude) longitude = try container.decodeIfPresent(Double.self, forKey: .longitude) entryMode = try container.decodeIfPresent(EntryMode.self, forKey: .entryMode) ?? .food consider = try container.decodeIfPresent(Bool.self, forKey: .consider) ?? false ibdb = try container.decodeIfPresent(String.self, forKey: .ibdb) ?? "" spectra = try container.decodeIfPresent(String.self, forKey: .spectra) ?? "" yelp = try container.decodeIfPresent(String.self, forKey: .yelp) ?? "" eaterNY = try container.decodeIfPresent(String.self, forKey: .eaterNY) ?? "" infatuation = try container.decodeIfPresent(String.self, forKey: .infatuation) ?? "" reservation = try container.decodeIfPresent(String.self, forKey: .reservation) ?? "" } /// Custom encoder: writes `imageFilename` but intentionally omits `imageData` /// so the JSON stays lean regardless of how large the image is. func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encode(playName, forKey: .playName) try container.encode(dateTime, forKey: .dateTime) try container.encode(cuisine, forKey: .cuisine) try container.encode(address, forKey: .address) if !phone.isEmpty { try container.encode(phone, forKey: .phone) } if !website.isEmpty { try container.encode(website, forKey: .website) } try container.encode(neighborhood, forKey: .neighborhood) try container.encodeIfPresent(hours.isEmpty ? nil : hours, forKey: .hours) try container.encode(confirmationLink, forKey: .confirmationLink) try container.encodeIfPresent(imageFilename, forKey: .imageFilename) // imageData intentionally skipped — stored on disk, not in JSON try container.encode(remindersEnabled, forKey: .remindersEnabled) try container.encode(notes, forKey: .notes) try container.encode(rating, forKey: .rating) try container.encode(beenThere, forKey: .beenThere) try container.encode(starRating, forKey: .starRating) try container.encode(hasCustomDate, forKey: .hasCustomDate) try container.encodeIfPresent(latitude, forKey: .latitude) try container.encodeIfPresent(longitude, forKey: .longitude) try container.encode(entryMode, forKey: .entryMode) if consider { try container.encode(consider, forKey: .consider) } // omit false to keep JSON lean if !ibdb.isEmpty { try container.encode(ibdb, forKey: .ibdb) } if !spectra.isEmpty { try container.encode(spectra, forKey: .spectra) } if !yelp.isEmpty { try container.encode(yelp, forKey: .yelp) } if !eaterNY.isEmpty { try container.encode(eaterNY, forKey: .eaterNY) } if !infatuation.isEmpty { try container.encode(infatuation, forKey: .infatuation) } if !reservation.isEmpty { try container.encode(reservation, forKey: .reservation) } } // MARK: - Equatable (exclude imageData from equality so star-rating edits don't // look like image changes and trigger unnecessary Watch syncs) static func == (lhs: SpotEntry, rhs: SpotEntry) -> Bool { lhs.id == rhs.id && lhs.playName == rhs.playName && lhs.dateTime == rhs.dateTime && lhs.cuisine == rhs.cuisine && lhs.entryMode == rhs.entryMode && lhs.address == rhs.address && lhs.phone == rhs.phone && lhs.website == rhs.website && lhs.neighborhood == rhs.neighborhood && lhs.hours == rhs.hours && lhs.confirmationLink == rhs.confirmationLink && lhs.imageFilename == rhs.imageFilename && lhs.remindersEnabled == rhs.remindersEnabled && lhs.notes == rhs.notes && lhs.rating == rhs.rating && lhs.beenThere == rhs.beenThere && lhs.starRating == rhs.starRating && lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude && lhs.consider == rhs.consider && lhs.ibdb == rhs.ibdb && lhs.spectra == rhs.spectra && lhs.yelp == rhs.yelp && lhs.eaterNY == rhs.eaterNY && lhs.infatuation == rhs.infatuation && lhs.reservation == rhs.reservation } // MARK: - Formatting private static let dateTimeFormatter: DateFormatter = { let f = DateFormatter() f.dateStyle = .medium f.timeStyle = .short return f }() var formattedDateTime: String { Self.dateTimeFormatter.string(from: dateTime) } // Fix "message:" links that are missing "//" static func fixMessageLink(_ link: String) -> String { if link.hasPrefix("message:") && !link.hasPrefix("message://") { return "message://" + link.dropFirst("message:".count) } return link } // Reminder intervals before showtime static let reminderIntervals: [(name: String, seconds: TimeInterval)] = [ ("1 week", 7 * 24 * 60 * 60), ("1 day", 24 * 60 * 60), ("6 hours", 6 * 60 * 60), ("1 hour", 60 * 60) ] }`SpotEntry` structDefines the `SpotEntry` struct. Conforms to Identifiable, Codable, Equatable.
class EntryStore: ObservableObject { @Published var entries: [SpotEntry] = []`EntryStore` classDefines the `EntryStore` class. Conforms to ObservableObject.
▶ BULK REMOVAL GUARD
/// Non-zero when a save was blocked because it would remove >1 entries vs the last confirmed state. /// ContentView observes this and shows a confirmation alert. @Published var blockedRemovalCount: Int = 0 /// The entries that were proposed when the removal was blocked (applied if user confirms). private var blockedProposedEntries: [SpotEntry] = [] /// Snapshot of entries as of the last successful save — used to detect bulk removals. private var lastConfirmedEntries: [SpotEntry] = []Documentation commentDescribes the following declaration.
▶ BULK LOAD GUARD
/// Non-zero when load() was blocked because merging would drop >1 entry vs. current /// in-memory set. ContentView observes this and shows a confirmation alert. @Published var blockedLoadCount: Int = 0 /// The merged entry array produced by the blocked load (applied if user confirms). private var blockedLoadedEntries: [SpotEntry] = []Documentation commentDescribes the following declaration.
▶ SESSION INTEGRITY GUARD
/// Entry count at the start of this session (reset on each load — including iCloud reloads). private var sessionStartCount: Int = 0 /// Net additions made during this session (between loads). private var sessionAddCount: Int = 0 /// Net deletions made during this session (between loads). private var sessionDeleteCount: Int = 0 /// Set to true when a save would produce fewer entries than the session accounting expects. /// ContentView observes this to show a confirmation alert. @Published var showingIntegrityWarning: Bool = false /// The actual entry count in the blocked save (for display in the alert). private(set) var integrityActualCount: Int = 0 /// The minimum count the session accounting predicts we should have (for display in the alert). private(set) var integrityExpectedCount: Int = 0 /// The proposed entries stashed while we wait for user confirmation. private var integrityProposedEntries: [SpotEntry] = [] private var metadataQuery: NSMetadataQuery? private var icloudObserver: NSObjectProtocol? private var geocodeMigrationInProgress = false /// Mod date of the iCloud file at the time of the last load. /// Used to skip reloads when metadata fires but file content hasn't changed. private var lastLoadedModDate: Date? deinit { if let observer = icloudObserver { NotificationCenter.default.removeObserver(observer) } metadataQuery?.stop() } /// Stops the iCloud metadata query and releases all associated resources. /// Call when the query is no longer needed (auto-called 30 s after start). func stopWatchingICloud() { if let observer = icloudObserver { NotificationCenter.default.removeObserver(observer) icloudObserver = nil } metadataQuery?.stop() metadataQuery = nil } /// User confirmed the blocked bulk removal — apply it and save. func confirmBlockedSave() { let proposed = blockedProposedEntries blockedRemovalCount = 0 blockedProposedEntries = [] lastConfirmedEntries = proposed entries = proposed // triggers onChange(of: entries) → save(), which now passes the guard } /// User cancelled the blocked bulk removal — discard it (entries already restored). func cancelBlockedSave() { blockedRemovalCount = 0 blockedProposedEntries = [] }Documentation commentDescribes the following declaration.
▶ BULK LOAD GUARD ACTIONS
/// User confirmed the blocked load — apply it and save. func confirmBlockedLoad() { let toLoad = blockedLoadedEntries blockedLoadCount = 0 blockedLoadedEntries = [] lastConfirmedEntries = toLoad entries = toLoad // triggers onChange(of: entries) → save() } /// User cancelled the blocked load — discard the incoming data; keep current entries. func cancelBlockedLoad() { blockedLoadCount = 0 blockedLoadedEntries = [] } /// Clears imageData from all in-memory entries to free memory. /// Safe to call at any time: custom == excludes imageData so this won't trigger save(). func clearImageData() { for i in entries.indices where entries[i].imageData != nil { entries[i].imageData = nil } }Documentation commentDescribes the following declaration.
▶ SESSION INTEGRITY GUARD ACTIONS
/// User confirmed the integrity-warning save — apply the proposed entries. func confirmIntegritySave() { let proposed = integrityProposedEntries // Update session counters to reflect this now-confirmed state so the // next save() pass doesn't re-trigger the warning. let prevIDs = Set(lastConfirmedEntries.map(\.id)) let newIDs = Set(proposed.map(\.id)) sessionAddCount += newIDs.subtracting(prevIDs).count sessionDeleteCount += prevIDs.subtracting(newIDs).count // Advance the confirmed baseline so save() sees a zero delta. lastConfirmedEntries = proposed showingIntegrityWarning = false integrityProposedEntries = [] entries = proposed // triggers onChange(of: entries) → save(), which now passes } /// User cancelled the integrity-warning save — keep existing entries. func cancelIntegritySave() { showingIntegrityWarning = false integrityProposedEntries = [] // entries is already restored to lastConfirmedEntries (done in save() when we blocked) }Documentation commentDescribes the following declaration.
▶ ICLOUD WATCHER
/// Start watching iCloud for remote changes to entries.json. /// Call once after load() — fires load() automatically whenever another /// device uploads a newer copy. func startWatchingICloud() { guard metadataQuery == nil else { return } // only set up once let query = NSMetadataQuery() query.predicate = NSPredicate(format: "%K == %@", NSMetadataItemFSNameKey, "entries.json") query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope] query.notificationBatchingInterval = 1.0 icloudObserver = NotificationCenter.default.addObserver( forName: .NSMetadataQueryDidUpdate, object: query, queue: .main ) { [weak self] notification in guard let self, let q = notification.object as? NSMetadataQuery else { return } q.disableUpdates() defer { q.enableUpdates() } // Only reload once the file is fully downloaded AND its content has changed. for i in 0..<q.resultCount { guard let item = q.result(at: i) as? NSMetadataItem else { continue } let status = item.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String let modDate = item.value(forAttribute: NSMetadataItemFSContentChangeDateKey) as? Date if status == NSMetadataUbiquitousItemDownloadingStatusCurrent { // Skip if we already loaded this exact version — prevents spurious // reloads when iCloud fires metadata updates without file changes, // and stops the save→iCloud→load loop that accumulates memory at idle. if let mod = modDate, mod == self.lastLoadedModDate { break } self.lastLoadedModDate = modDate print("ℹ️ iCloud entries.json updated — reloading") Task { await self.load() } break } } } DispatchQueue.main.async { query.start() } metadataQuery = query // Auto-stop after 30 seconds. The query monitors the ENTIRE iCloud Documents // directory (including all image files), so keeping it running indefinitely // causes steady memory growth as the metadata service accumulates tracking state. // ContentView restarts it on willEnterForeground to catch any remote changes. DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in self?.stopWatchingICloud() } }Documentation commentDescribes the following declaration.
▶ LAUNCH BACKUP
/// Call once at launch BEFORE load(). Copies the current entries.json to a /// rotating set of 3 backup files both locally and in iCloud. /// (backup.1 = newest, backup.3 = oldest) /// Only backs up if the source file exists and contains at least 1 entry. static func backupOnLaunch() { DispatchQueue.global(qos: .utility).async { let fm = FileManager.default guard let localDocsDir = try? fm.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) else { return } // Prefer iCloud copy (most up-to-date), fall back to local let icloudURL = icloudFileURL() let localURL = try? localFileURL() let source: URL? if let ic = icloudURL, fm.fileExists(atPath: ic.path) { source = ic } else if let lo = localURL, fm.fileExists(atPath: lo.path) { source = lo } else { return // nothing to back up yet } // Sanity-check: only back up if file has at least 1 entry guard let data = try? Data(contentsOf: source!), let arr = try? JSONDecoder().decode([SpotEntry].self, from: data), !arr.isEmpty else { print("ℹ️ Backup skipped — source is empty or unreadable") return } // --- Local backup (rotate 5 slots: .1 = newest, .5 = oldest) --- let slot1 = localDocsDir.appendingPathComponent("entries.backup.1.json") let slot2 = localDocsDir.appendingPathComponent("entries.backup.2.json") let slot3 = localDocsDir.appendingPathComponent("entries.backup.3.json") let slot4 = localDocsDir.appendingPathComponent("entries.backup.4.json") let slot5 = localDocsDir.appendingPathComponent("entries.backup.5.json") try? fm.removeItem(at: slot5) if fm.fileExists(atPath: slot4.path) { try? fm.moveItem(at: slot4, to: slot5) } if fm.fileExists(atPath: slot3.path) { try? fm.moveItem(at: slot3, to: slot4) } if fm.fileExists(atPath: slot2.path) { try? fm.moveItem(at: slot2, to: slot3) } if fm.fileExists(atPath: slot1.path) { try? fm.moveItem(at: slot1, to: slot2) } try? fm.copyItem(at: source!, to: slot1) print("ℹ️ Local backup created: entries.backup.1.json (\(arr.count) entries)") // --- iCloud backup (same 5-slot rotation, stored under iCloud Documents/Backups/) --- if let icloudDocsDir = fm.url(forUbiquityContainerIdentifier: nil)? .appendingPathComponent("Documents") .appendingPathComponent("Backups") { // Ensure the Backups folder exists try? fm.createDirectory(at: icloudDocsDir, withIntermediateDirectories: true) let ic1 = icloudDocsDir.appendingPathComponent("entries.backup.1.json") let ic2 = icloudDocsDir.appendingPathComponent("entries.backup.2.json") let ic3 = icloudDocsDir.appendingPathComponent("entries.backup.3.json") let ic4 = icloudDocsDir.appendingPathComponent("entries.backup.4.json") let ic5 = icloudDocsDir.appendingPathComponent("entries.backup.5.json") try? fm.removeItem(at: ic5) if fm.fileExists(atPath: ic4.path) { try? fm.moveItem(at: ic4, to: ic5) } if fm.fileExists(atPath: ic3.path) { try? fm.moveItem(at: ic3, to: ic4) } if fm.fileExists(atPath: ic2.path) { try? fm.moveItem(at: ic2, to: ic3) } if fm.fileExists(atPath: ic1.path) { try? fm.moveItem(at: ic1, to: ic2) } do { try data.write(to: ic1) print("ℹ️ iCloud backup created: Backups/entries.backup.1.json (\(arr.count) entries)") } catch { print("⚠️ iCloud backup failed: \(error)") } } else { print("ℹ️ iCloud backup skipped — iCloud not available") } } }Documentation commentDescribes the following declaration.
▶ FILE URLS
nonisolated private static func localFileURL() throws -> URL { try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) .appendingPathComponent("entries.json") } nonisolated private static func icloudFileURL() -> URL? { FileManager.default.url(forUbiquityContainerIdentifier: nil)? .appendingPathComponent("Documents") .appendingPathComponent("entries.json") } /// Loads the image file for the given filename from iCloud (preferred) or local storage. /// Returns nil if the file is absent or unreadable. Always call from a background context. /// Prefer loadThumbnail() for display in views — it avoids decoding the full image. static func loadImageData(filename: String) -> Data? { guard !filename.isEmpty else { return nil } if let dir = icloudImagesDirectory() { let url = dir.appendingPathComponent(filename) if FileManager.default.fileExists(atPath: url.path), let data = try? Data(contentsOf: url) { return data } } if let dir = try? localImagesDirectory() { let url = dir.appendingPathComponent(filename) if FileManager.default.fileExists(atPath: url.path), let data = try? Data(contentsOf: url) { return data } } return nil } /// Load a downsampled thumbnail for display in list rows or detail views. /// Uses ImageIO so only the pixels needed are decoded — avoids decompressing /// a full-resolution image (which can be 10–20 MB) just to show a thumbnail. /// /// - Parameter maxPixelSize: longest edge in pixels (e.g. 150 for a 50-pt /// row thumbnail on a 3× screen, 400 for a 130-pt detail image). static func loadThumbnail(filename: String, maxPixelSize: CGFloat) async -> UIImage? { guard !filename.isEmpty else { return nil } return await Task.detached(priority: .userInitiated) { // Locate the file (iCloud first, then local) var fileURL: URL? if let dir = Self.icloudImagesDirectory() { let candidate = dir.appendingPathComponent(filename) if FileManager.default.fileExists(atPath: candidate.path) { fileURL = candidate } } if fileURL == nil, let dir = try? Self.localImagesDirectory() { let candidate = dir.appendingPathComponent(filename) if FileManager.default.fileExists(atPath: candidate.path) { fileURL = candidate } } guard let url = fileURL else { return nil } let sourceOptions: [CFString: Any] = [kCGImageSourceShouldCache: false] guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions as CFDictionary) else { return nil } let thumbOptions: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceShouldCacheImmediately: true ] guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbOptions as CFDictionary) else { return nil } return UIImage(cgImage: cgImage) }.value } /// Resize raw image data so its longest edge does not exceed maxDimension. /// Returns the original data unchanged if it is already within the limit. nonisolated static func resizedImageData(_ data: Data, maxDimension: CGFloat, compressionQuality: CGFloat = 0.80) -> Data { let sourceOptions: [CFString: Any] = [kCGImageSourceShouldCache: false] guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions as CFDictionary), let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] else { return data } let w = (props[kCGImagePropertyPixelWidth] as? CGFloat) ?? 0 let h = (props[kCGImagePropertyPixelHeight] as? CGFloat) ?? 0 guard max(w, h) > maxDimension else { return data } let thumbOptions: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceThumbnailMaxPixelSize: maxDimension, kCGImageSourceCreateThumbnailWithTransform: true ] guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbOptions as CFDictionary) else { return data } return UIImage(cgImage: cgImage).jpegData(compressionQuality: compressionQuality) ?? data } /// Local directory for image files (created on first use). nonisolated private static func localImagesDirectory() throws -> URL { let docs = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let dir = docs.appendingPathComponent("SpotsImages", isDirectory: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir } /// iCloud directory for image files. nonisolated private static func icloudImagesDirectory() -> URL? { guard let icloudDocs = FileManager.default.url(forUbiquityContainerIdentifier: nil)? .appendingPathComponent("Documents") else { return nil } let dir = icloudDocs.appendingPathComponent("SpotsImages", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir }`localFileURL()` functionImplements `localFileURL`. Can throw errors. Returns `URL`.
▶ LOAD
/// Loads entries from disk (local or iCloud). Properly async — callers can /// `await store.load()` and be guaranteed that `entries` is populated before /// the next line runs. This prevents the race where `importSharedEntry()` /// appends to an empty array before load finishes. func load() async { await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in Task.detached(priority: .userInitiated) { [weak self] in guard let self else { continuation.resume(); return } let localURL = try? Self.localFileURL() let icloudURL = Self.icloudFileURL() // Ask iCloud to download the latest copy if it hasn't already if let icloudURL { try? FileManager.default.startDownloadingUbiquitousItem(at: icloudURL) } let localExists = localURL.map { FileManager.default.fileExists(atPath: $0.path) } ?? false let icloudExists = icloudURL.map { FileManager.default.fileExists(atPath: $0.path) } ?? false guard localExists || icloudExists else { continuation.resume(); return } do { let decoder = JSONDecoder() var icloudEntries: [SpotEntry] = [] if icloudExists, let icloud = icloudURL, let data = try? Data(contentsOf: icloud) { icloudEntries = (try? decoder.decode([SpotEntry].self, from: data)) ?? [] } var localEntries: [SpotEntry] = [] if localExists, let local = localURL, let data = try? Data(contentsOf: local) { localEntries = (try? decoder.decode([SpotEntry].self, from: data)) ?? [] } var mergedByID: [UUID: SpotEntry] = Dictionary( icloudEntries.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a } ) var localOnlyCount = 0 for entry in localEntries where mergedByID[entry.id] == nil { mergedByID[entry.id] = entry localOnlyCount += 1 } var decoded = Array(mergedByID.values) .sorted { $0.playName.lowercased() < $1.playName.lowercased() } print("ℹ️ Loaded \(icloudEntries.count) from iCloud, \(localEntries.count) from local → \(decoded.count) merged (\(localOnlyCount) local-only added)") // Migration only: if any entry still has an inline imageData blob (old format), // write it to a file and set imageFilename. New-format entries already have // imageFilename set and their image files live on disk — they are loaded lazily // by the view layer (EntryRowView, EntryDetailView, EditEntryView) so we don't // pay the I/O cost for every image on every launch. let localImagesDir = try Self.localImagesDirectory() let icloudImagesDir = Self.icloudImagesDirectory() var needsResave = false for i in decoded.indices { guard decoded[i].imageFilename == nil || decoded[i].imageFilename!.isEmpty, let inlineData = decoded[i].imageData else { continue } // Old format: image was stored inline — migrate to a file now let filename = "\(decoded[i].id.uuidString).jpg" try? inlineData.write(to: localImagesDir.appendingPathComponent(filename)) if let dir = icloudImagesDir { try? inlineData.write(to: dir.appendingPathComponent(filename)) } decoded[i].imageFilename = filename // imageData stays populated in memory; JSON will be updated on next save needsResave = true print("ℹ️ Migrated inline image for '\(decoded[i].playName)' → \(filename)") } // Migration: clean up addresses that Apple Maps formats oddly. let usSuffixes = [", United States", ", USA"] for i in decoded.indices { var addr = decoded[i].address // 1. Strip trailing ", United States" / ", USA" for suffix in usSuffixes where addr.hasSuffix(suffix) { addr = String(addr.dropLast(suffix.count)) .trimmingCharacters(in: .whitespaces) needsResave = true break } // 2. "Manhattan, NY" → "New York, NY" // (Manhattan is not a USPS city; New York is the correct mailing city.) let manhattanFixed = addr.replacingOccurrences( of: "Manhattan, NY", with: "New York, NY", options: .caseInsensitive) if manhattanFixed != addr { addr = manhattanFixed needsResave = true } decoded[i].address = addr } // Capture mutable locals as immutable constants before crossing // into the MainActor context (Swift 6 disallows captured `var`s in // concurrently-executing closures). let capturedDecoded = decoded let capturedNeedsResave = needsResave await MainActor.run { // Work with a local mutable copy inside the actor context. var merged = capturedDecoded // Merge: preserve any entries currently in memory that are absent // from the loaded data. This protects freshly-imported entries that // haven't yet propagated to iCloud — without this, a rapid // iCloud-watcher reload can silently drop a just-added entry, // leaving the edit sheet pointing at a missing entry (white screen). let loadedIDs = Set(merged.map(\.id)) let onlyInMemory = self.entries.filter { !loadedIDs.contains($0.id) } if !onlyInMemory.isEmpty { merged.append(contentsOf: onlyInMemory) print("ℹ️ Load: preserved \(onlyInMemory.count) in-flight entry(s) not yet on disk") } // Bulk-load guard: if applying this load would drop more than 1 entry // from what's currently in memory, block it and ask for confirmation. // Catches the case where iCloud hasn't finished downloading a restored // backup before the merge runs, producing a spuriously small result. let currentCount = self.entries.count if currentCount > 0 && (currentCount - merged.count) > 1 { print("⚠️ Load blocked: would remove \(currentCount - merged.count) entries (\(currentCount) → \(merged.count))") self.blockedLoadedEntries = merged self.blockedLoadCount = currentCount - merged.count continuation.resume() return } self.entries = merged self.lastConfirmedEntries = merged // establish baseline for bulk-removal guard // Reset session integrity counters — each load is a fresh baseline. // This handles both initial launch and iCloud-triggered reloads. self.sessionStartCount = merged.count self.sessionAddCount = 0 self.sessionDeleteCount = 0 // If we migrated any inline images, persist the updated filenames right away if capturedNeedsResave { self.save() } // Backfill coordinates for entries that predate coordinate storage self.geocodeMissingCoordinates() continuation.resume() } } catch { print("Error loading entries: \(error)") continuation.resume() } } // end Task.detached } // end withCheckedContinuation }Documentation commentDescribes the following declaration.
▶ SAVE
func save() { let proposed = entries // Bulk-removal guard: block any save that would drop more than 1 entry at once. // Runs on the main thread (save() is called from onChange(of: entries)). if !lastConfirmedEntries.isEmpty { let removedCount = lastConfirmedEntries.count - proposed.count if removedCount > 1 { print("⚠️ Bulk removal blocked: \(removedCount) entries would be removed at once") blockedProposedEntries = proposed blockedRemovalCount = removedCount entries = lastConfirmedEntries // restore — triggers another save() call that passes the guard return } } // Session integrity check: compare proposed count against what the session // accounting predicts we should have (start + adds - deletes). // Only active once we've loaded at least once (sessionStartCount > 0). if sessionStartCount > 0 { let prevIDs = Set(lastConfirmedEntries.map(\.id)) let newIDs = Set(proposed.map(\.id)) let deltaAdds = newIDs.subtracting(prevIDs).count let deltaDeletes = prevIDs.subtracting(newIDs).count let expectedMin = sessionStartCount + sessionAddCount + deltaAdds - sessionDeleteCount - deltaDeletes if proposed.count < expectedMin { print("⚠️ Session integrity check failed: have \(proposed.count), expected ≥ \(expectedMin) " + "(start=\(sessionStartCount) +\(sessionAddCount + deltaAdds) adds " + "-\(sessionDeleteCount + deltaDeletes) deletes)") integrityProposedEntries = proposed integrityActualCount = proposed.count integrityExpectedCount = expectedMin showingIntegrityWarning = true entries = lastConfirmedEntries // restore return } // Safe — commit the session delta sessionAddCount += deltaAdds sessionDeleteCount += deltaDeletes } lastConfirmedEntries = proposed // Capture the current entry array (value type — safe to use off-main) let currentEntries = proposed DispatchQueue.global(qos: .utility).async { var toSave = currentEntries // Ensure every entry with imageData has a filename, and write new/changed images let localImagesDir = try? Self.localImagesDirectory() let icloudImagesDir = Self.icloudImagesDirectory() for i in toSave.indices { guard let imgData = toSave[i].imageData else { continue } // Assign filename if not yet set if toSave[i].imageFilename == nil || toSave[i].imageFilename!.isEmpty { toSave[i].imageFilename = "\(toSave[i].id.uuidString).jpg" } guard let filename = toSave[i].imageFilename else { continue } if let dir = localImagesDir { try? imgData.write(to: dir.appendingPathComponent(filename)) } if let dir = icloudImagesDir { try? imgData.write(to: dir.appendingPathComponent(filename)) } } // Release imageData from in-memory entries now that files are on disk. // Custom == excludes imageData so this won't re-trigger save(). // Single batched assignment → one objectWillChange, not N. DispatchQueue.main.async { [weak self] in guard let self else { return } self.clearImageData() } guard let data = try? JSONEncoder().encode(toSave) else { return } // Always save locally first (fast, reliable) if let localURL = try? Self.localFileURL() { do { try data.write(to: localURL) } catch { print("Error saving entries locally: \(error)") } } // Save to iCloud guard let icloudURL = Self.icloudFileURL() else { print("⚠️ iCloud not available — skipping iCloud save") return } let docsURL = icloudURL.deletingLastPathComponent() do { try FileManager.default.createDirectory(at: docsURL, withIntermediateDirectories: true) try data.write(to: icloudURL) print("ℹ️ Entries saved to iCloud (\(toSave.count) entry(s))") } catch { print("❌ Failed to save entries to iCloud: \(error)") } } }`save()` functionImplements `save`.
▶ BACKUP RESTORE
/// Returns metadata for each local backup slot that exists and is non-empty, /// newest first (slot 1 → slot 5). struct BackupInfo: Identifiable { let id: Int // slot number 1-5 let url: URL let date: Date let entryCount: Int } func availableBackups() -> [BackupInfo] { guard let docsDir = try? FileManager.default.url( for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) else { return [] } return (1...5).compactMap { slot -> BackupInfo? in let url = docsDir.appendingPathComponent("entries.backup.\(slot).json") guard FileManager.default.fileExists(atPath: url.path), let data = try? Data(contentsOf: url), let arr = try? JSONDecoder().decode([SpotEntry].self, from: data), !arr.isEmpty, let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), let modDate = attrs[.modificationDate] as? Date else { return nil } return BackupInfo(id: slot, url: url, date: modDate, entryCount: arr.count) } } /// Replaces the current entry list with the contents of the given backup file. /// Bypasses all bulk-removal guards (this is an intentional full restore). func restore(from backup: BackupInfo) async { guard let data = try? Data(contentsOf: backup.url), let restored = try? JSONDecoder().decode([SpotEntry].self, from: data) else { print("⚠️ Restore failed: could not decode \(backup.url.lastPathComponent)") return } await MainActor.run { // Reset guard state so save() doesn't block the restored array lastConfirmedEntries = restored sessionStartCount = restored.count sessionAddCount = 0 sessionDeleteCount = 0 entries = restored } print("✅ Restored \(restored.count) entries from backup slot \(backup.id)") }Documentation commentDescribes the following declaration.
▶ COORDINATE GEOCODING
/// Geocodes a single entry's address and stores the result back into the entries array. /// Also corrects the city name and fills in a missing zip code. /// /// Strategy: /// - If the address contains a zip, geocode the zip alone to get the authoritative city /// (prevents the geocoder from accepting a wrong city and returning it back unchanged), /// then geocode the full address for accurate street-level coordinates. /// - If there is no zip, one geocoder call on the full address handles both. func geocodeEntry(id: UUID) { guard let idx = entries.firstIndex(where: { $0.id == id }) else { return } let address = geocodeAddress(for: entries[idx]) guard !address.isEmpty else { return } // Extract zip code if present let zipRegex = try? NSRegularExpression(pattern: #"\b(\d{5})\b"#) let zipRange = zipRegex?.firstMatch(in: address, range: NSRange(address.startIndex..., in: address)) .flatMap { Range($0.range(at: 1), in: address) } let zip = zipRange.map { String(address[$0]) } Task { @MainActor [weak self] in guard let self else { return } if let zip { // Step 1 — search zip+USA for authoritative city lookup. // Adding "USA" prevents misreading a bare 5-digit number. let zipReq = MKLocalSearch.Request() zipReq.naturalLanguageQuery = "\(zip), USA" if let zipItem = try? await MKLocalSearch(request: zipReq).start().mapItems.first, let i = self.entries.firstIndex(where: { $0.id == id }), let corrected = Self.normalizedAddress(self.entries[i].address, from: zipItem.placemark), corrected != self.entries[i].address { print("📍 Address normalized: '\(self.entries[i].address)' → '\(corrected)'") self.entries[i].address = corrected } // Step 2 — search the full (possibly corrected) address for coordinates. // 0.5 s gap mirrors the old CLGeocoder rate-limit delay. try? await Task.sleep(nanoseconds: 500_000_000) let fullAddress = self.entries.first(where: { $0.id == id })?.address ?? address let fullReq = MKLocalSearch.Request() fullReq.naturalLanguageQuery = fullAddress if let item = try? await MKLocalSearch(request: fullReq).start().mapItems.first, let i = self.entries.firstIndex(where: { $0.id == id }) { let coord = item.location.coordinate self.entries[i].latitude = coord.latitude self.entries[i].longitude = coord.longitude } } else { // No zip — single search handles both city normalization and coordinates. let req = MKLocalSearch.Request() req.naturalLanguageQuery = address if let item = try? await MKLocalSearch(request: req).start().mapItems.first, let i = self.entries.firstIndex(where: { $0.id == id }) { let coord = item.location.coordinate self.entries[i].latitude = coord.latitude self.entries[i].longitude = coord.longitude if let corrected = Self.normalizedAddress(self.entries[i].address, from: item.placemark), corrected != self.entries[i].address { print("📍 Address normalized: '\(self.entries[i].address)' → '\(corrected)'") self.entries[i].address = corrected } } } } } /// US state name → 2-letter abbreviation lookup. private static let stateAbbreviations: [String: String] = [ "Alabama": "AL", "Alaska": "AK", "Arizona": "AZ", "Arkansas": "AR", "California": "CA", "Colorado": "CO", "Connecticut": "CT", "Delaware": "DE", "Florida": "FL", "Georgia": "GA", "Hawaii": "HI", "Idaho": "ID", "Illinois": "IL", "Indiana": "IN", "Iowa": "IA", "Kansas": "KS", "Kentucky": "KY", "Louisiana": "LA", "Maine": "ME", "Maryland": "MD", "Massachusetts": "MA", "Michigan": "MI", "Minnesota": "MN", "Mississippi": "MS", "Missouri": "MO", "Montana": "MT", "Nebraska": "NE", "Nevada": "NV", "New Hampshire": "NH", "New Jersey": "NJ", "New Mexico": "NM", "New York": "NY", "North Carolina": "NC", "North Dakota": "ND", "Ohio": "OH", "Oklahoma": "OK", "Oregon": "OR", "Pennsylvania": "PA", "Rhode Island": "RI", "South Carolina": "SC", "South Dakota": "SD", "Tennessee": "TN", "Texas": "TX", "Utah": "UT", "Vermont": "VT", "Virginia": "VA", "Washington": "WA", "West Virginia": "WV", "Wisconsin": "WI", "Wyoming": "WY", "District of Columbia": "DC" ] /// Normalises the state in a single address string without any network call. /// Returns the corrected address, or nil if no change is needed. /// Safe to call on every save. static func normalizeStateAbbreviation(in address: String) -> String? { guard !address.isEmpty else { return nil } let pattern = #"^(.*?),\s*([^,]+),\s*([A-Za-z][A-Za-z ]*?)\s*(\d{5})?\s*$"# guard let regex = try? NSRegularExpression(pattern: pattern), let match = regex.firstMatch(in: address, range: NSRange(address.startIndex..., in: address)), match.numberOfRanges == 5 else { return nil } func capture(_ i: Int) -> String? { guard let r = Range(match.range(at: i), in: address), !address[r].isEmpty else { return nil } return String(address[r]).trimmingCharacters(in: .whitespaces) } let street = capture(1) ?? "" let city = capture(2) ?? "" let state = capture(3) ?? "" let zip = capture(4) guard let abbr = stateAbbreviations[state] else { return nil } // already abbreviated var result = "\(street), \(city), \(abbr)" if let z = zip { result += " \(z)" } return result } /// One-time migration: abbreviates full state names in all entries. /// Applies purely in-memory and triggers a single save if anything changed. func normalizeAllStateAbbreviations() { var changed = false for i in entries.indices { if let fixed = EntryStore.normalizeStateAbbreviation(in: entries[i].address), fixed != entries[i].address { print("📍 State abbreviated: '\(entries[i].address)' → '\(fixed)'") entries[i].address = fixed changed = true } } if changed { save() } } /// Returns a corrected copy of `address` with the city fixed, zip added if missing, /// and the state abbreviation normalised (e.g. "New York" → "NY"). /// Returns nil when no change is needed. /// /// Handles both formats produced by Claude: /// "124 E 14th St, New York, New York 10003" ← full state name /// "28 Bowery, New York, NY 10013" ← abbreviated state /// "512 7th Avenue, New York, NY" ← no zip private static func normalizedAddress(_ address: String, from placemark: CLPlacemark) -> String? { guard !address.isEmpty else { return nil } // State can be a 2-letter abbreviation ("NY") or a full name ("New York"). // Group 3 uses a non-greedy letters+spaces pattern so it correctly captures // multi-word state names while leaving the trailing zip for group 4. let pattern = #"^(.*?),\s*([^,]+),\s*([A-Za-z][A-Za-z ]*?)\s*(\d{5})?\s*$"# guard let regex = try? NSRegularExpression(pattern: pattern), let match = regex.firstMatch(in: address, range: NSRange(address.startIndex..., in: address)), match.numberOfRanges == 5 else { return nil } func capture(_ i: Int) -> String? { guard let r = Range(match.range(at: i), in: address), !address[r].isEmpty else { return nil } return String(address[r]).trimmingCharacters(in: .whitespaces) } let street = capture(1) ?? "" let addrCity = capture(2) ?? "" let addrState = capture(3) ?? "" let addrZip = capture(4) // nil when absent // Normalise full state name → 2-letter abbreviation if needed. let correctedState = stateAbbreviations[addrState] ?? addrState // Resolve the conventional postal city name from the placemark. // CLGeocoder returns "New York" or "New York City" as the locality for ALL five // NYC boroughs. For outer boroughs the borough name is in subAdministrativeArea // (e.g. "Kings County" for Brooklyn). Map county → postal city name accordingly. let rawLocality = placemark.locality ?? "" let subAdmin = placemark.subAdministrativeArea ?? "" let nycLocalities = Set(["New York", "New York City"]) let locality: String if nycLocalities.contains(rawLocality) { switch subAdmin { case "Kings County": locality = "Brooklyn" case "Bronx County": locality = "Bronx" case "Queens County": locality = "Queens" case "Richmond County": locality = "Staten Island" default: locality = "New York" // Manhattan / unknown } } else { locality = rawLocality } let placeZip = placemark.postalCode let correctedCity = (!locality.isEmpty && locality.caseInsensitiveCompare(addrCity) != .orderedSame) ? locality : addrCity let correctedZip = addrZip ?? placeZip // keep existing zip; add if absent // Nothing changed — don't touch the address if correctedCity == addrCity && correctedZip == addrZip && correctedState == addrState { return nil } var result = "\(street), \(correctedCity), \(correctedState)" if let z = correctedZip { result += " \(z)" } return result } /// Temporary migration: geocodes all entries that have no stored coordinates, /// accumulates results, and applies them in ONE batch update — a single save at the end. /// Becomes a no-op once all entries have coords (or all have been attempted). func geocodeMissingCoordinates() { guard !geocodeMigrationInProgress else { return } // Skip IDs that have already been attempted in a previous session let attemptedIDs = Set((UserDefaults.standard.array(forKey: "geocodeAttemptedIDs") as? [String] ?? []).compactMap { UUID(uuidString: $0) }) let missing = entries.filter { $0.latitude == nil && !attemptedIDs.contains($0.id) && !geocodeAddress(for: $0).isEmpty } guard !missing.isEmpty else { return } geocodeMigrationInProgress = true print("ℹ️ Geocoding \(missing.count) entries without stored coordinates…") geocodeMigrationSequentially(ids: missing.map(\.id), index: 0, accumulator: [:]) } private func geocodeMigrationSequentially(ids: [UUID], index: Int, accumulator: [UUID: (Double, Double)]) { guard index < ids.count else { // Record all attempted IDs so we don't retry them on future reloads let alreadyAttempted = (UserDefaults.standard.array(forKey: "geocodeAttemptedIDs") as? [String]) ?? [] let newAttempted = alreadyAttempted + ids.map(\.uuidString) UserDefaults.standard.set(newAttempted, forKey: "geocodeAttemptedIDs") geocodeMigrationInProgress = false guard !accumulator.isEmpty else { print("ℹ️ Coordinate migration complete (0 geocoded — will not retry these entries)") return } // Apply all results in one batch — one @Published mutation, one save var acc = accumulator for i in entries.indices { if let (lat, lon) = acc[entries[i].id] { entries[i].latitude = lat entries[i].longitude = lon acc.removeValue(forKey: entries[i].id) } } save() print("ℹ️ Coordinate migration complete (\(accumulator.count) geocoded)") return } let id = ids[index] guard let idx = entries.firstIndex(where: { $0.id == id }) else { geocodeMigrationSequentially(ids: ids, index: index + 1, accumulator: accumulator) return } let address = geocodeAddress(for: entries[idx]) var acc = accumulator Task { @MainActor [weak self] in guard let self else { return } let searchReq = MKLocalSearch.Request() searchReq.naturalLanguageQuery = address if let response = try? await MKLocalSearch(request: searchReq).start(), let item = response.mapItems.first { let coord = item.location.coordinate acc[id] = (coord.latitude, coord.longitude) } try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s rate-limit self.geocodeMigrationSequentially(ids: ids, index: index + 1, accumulator: acc) } } private func geocodeAddress(for entry: SpotEntry) -> String { if !entry.address.isEmpty { return entry.address } let fallback = [entry.playName, entry.cuisine, "New York City"] .filter { !$0.isEmpty }.joined(separator: " ") return fallback }Documentation commentDescribes the following declaration.
▶ WEBSITE MIGRATION
private var websiteMigrationInProgress = false /// One-time migration: extracts website URLs for all entries that have a /// confirmationLink but no website. Becomes a no-op once every eligible /// entry has been attempted (tracked via UserDefaults). /// One-time migration: any `.place` entry whose category suggests it is a shop /// (e.g. "Store", "Shop", "Boutique", etc.) gets moved to `.shop`. /// Guarded by a UserDefaults flag so it runs exactly once. func migrateShopsFromSites() { guard !UserDefaults.standard.bool(forKey: "shopMigrationDone") else { return } let shopKeywords = ["store", "shop", "boutique", "pharmacy", "drugstore", "hardware", "supermarket", "market", "grocer", "bookstore", "toy", "gift", "clothing", "apparel", "fashion", "jewelry", "stationery", "electronics", "furniture", "department", "antique", "vintage", "thrift"] var changed = false for i in entries.indices where entries[i].entryMode == .place { let category = entries[i].cuisine.lowercased() if shopKeywords.contains(where: { category.contains($0) }) { print("ℹ️ Shop migration: '\(entries[i].playName)' (\(entries[i].cuisine)) → .shop") entries[i].entryMode = .shop changed = true } } if changed { save() } UserDefaults.standard.set(true, forKey: "shopMigrationDone") print("ℹ️ Shop migration complete") } func backfillMissingWebsites() { guard !websiteMigrationInProgress else { return } let attemptedIDs = Set( (UserDefaults.standard.array(forKey: "websiteAttemptedIDs") as? [String] ?? []) .compactMap { UUID(uuidString: $0) } ) let candidates = entries.filter { $0.website.isEmpty && !$0.confirmationLink.isEmpty && !attemptedIDs.contains($0.id) } guard !candidates.isEmpty else { return } websiteMigrationInProgress = true print("ℹ️ Website migration: back-filling \(candidates.count) entries…") websiteMigrationSequentially(ids: candidates.map(\.id), index: 0) } private func websiteMigrationSequentially(ids: [UUID], index: Int) { guard index < ids.count else { // Mark all attempted so we never retry let already = (UserDefaults.standard.array(forKey: "websiteAttemptedIDs") as? [String]) ?? [] UserDefaults.standard.set(already + ids.map(\.uuidString), forKey: "websiteAttemptedIDs") websiteMigrationInProgress = false print("ℹ️ Website migration complete") return } let id = ids[index] guard let entry = entries.first(where: { $0.id == id }) else { websiteMigrationSequentially(ids: ids, index: index + 1) return } PhoneExtractor.extract(for: entry) { [weak self] _, website in guard let self else { return } if !website.isEmpty, let i = self.entries.firstIndex(where: { $0.id == id }) { self.entries[i].website = website print("🌐 Website back-filled for '\(self.entries[i].playName)': \(website)") self.save() } // Small delay to avoid hammering the API DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { self.websiteMigrationSequentially(ids: ids, index: index + 1) } } } }`websiteMigrationInProgress` varProperty `websiteMigrationInProgress`.