| Code | What It Does | How It Does It |
| ▶ IMPORTS | | |
| import Foundation
import Combine
import CoreLocation
import ImageIO
import MapKit
import UIKit | Framework imports | Imports Foundation, Combine, CoreLocation, ImageIO, MapKit, UIKit. |
| ▶ ENTRY MODE (FOOD / SHOW / PLACE) | | |
| enum EntryMode: String, Codable {
case food, show, place, shop, event
} | `EntryMode` enum | Defines 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` struct | Defines the `SpotEntry` struct. Conforms to Identifiable, Codable, Equatable. |
| class EntryStore: ObservableObject {
@Published var entries: [SpotEntry] = [] | `EntryStore` class | Defines 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 comment | Describes 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 comment | Describes 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 comment | Describes 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 comment | Describes 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 comment | Describes 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 comment | Describes 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 comment | Describes 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()` function | Implements `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 comment | Describes 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()` function | Implements `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 comment | Describes 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 comment | Describes 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` var | Property `websiteMigrationInProgress`. |