Primary sync: replace PersistenceActor JSON file with SwiftData + CloudKit - Add SavedCardModel (@Model class) and PersistenceController (ModelContainer with .automatic CloudKit, fallback to local). BackgroundPersistenceActor (@ModelActor) handles all DB I/O off the main thread. - One-time migration imports user_collection.json into SwiftData and renames the original file to prevent re-import. - Inject modelContainer into SwiftUI environment in IYmtgApp. Image storage: Documents/UserContent/ subfolder (blueprint requirement) - ImageManager.dir now targets iCloud Documents/UserContent/ (or local equiv). - migrateImagesToUserContent() moves existing JPGs to the new subfolder on first launch; called during the SwiftData migration. Firebase: demoted to optional manual backup (metadata only, no images) - Remove all automatic CloudEngine.save/delete/batchUpdatePrices calls from CollectionViewModel mutations. - Add backupAllToFirebase() for user-triggered metadata sync. - Add isFirebaseBackupEnabled to AppConfig (default false). - Add Cloud Backup section in Library settings with iCloud vs Firebase explanation and "Backup Metadata to Firebase Now" button. Also: full modular refactor (Data/, Features/, Services/ directories) and README updated with CloudKit setup steps and revised release checklist. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
122 lines
6.4 KiB
Swift
122 lines
6.4 KiB
Swift
import Foundation
|
|
|
|
// MARK: - SCRYFALL THROTTLER
|
|
actor ScryfallThrottler {
|
|
static let shared = ScryfallThrottler()
|
|
private var nextAllowedTime = Date.distantPast
|
|
|
|
func wait() async {
|
|
let now = Date()
|
|
let targetTime = max(now, nextAllowedTime)
|
|
nextAllowedTime = targetTime.addingTimeInterval(0.1)
|
|
|
|
let waitTime = targetTime.timeIntervalSince(now)
|
|
if waitTime > 0 {
|
|
try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - SCRYFALL API (formerly InsuranceEngine)
|
|
class ScryfallAPI {
|
|
struct PricingIdentifier: Codable { let set: String; let collector_number: String }
|
|
struct ScryfallData {
|
|
let price: Double?
|
|
let typeLine: String
|
|
let rarity: String?
|
|
let colors: [String]?
|
|
let isSerialized: Bool
|
|
}
|
|
|
|
static func getPriceKey(foilType: String, currency: CurrencyCode) -> String {
|
|
if foilType.caseInsensitiveCompare("Etched") == .orderedSame { return "\(currency.scryfallKey)_etched" }
|
|
let isFoil = foilType != "None" && foilType != AppConfig.Defaults.defaultFoil
|
|
let base = currency.scryfallKey
|
|
return isFoil ? "\(base)_foil" : base
|
|
}
|
|
|
|
static func updateTrends(cards: [SavedCard], currency: CurrencyCode, force: Bool = false, updateTimestamp: Bool = true) async -> ([UUID: Double], [UUID: (String?, [String]?, Bool)]) {
|
|
if !NetworkMonitor.shared.isConnected { return ([:], [:]) }
|
|
let lastUpdate = UserDefaults.standard.object(forKey: "LastPriceUpdate") as? Date ?? Date.distantPast
|
|
let isCacheExpired = Date().timeIntervalSince(lastUpdate) >= 86400
|
|
|
|
// Optimization: If cache is fresh, only fetch cards that are missing a price (Smart Partial Refresh)
|
|
let cardsToFetch = (force || isCacheExpired) ? cards : cards.filter { $0.currentValuation == nil }
|
|
if cardsToFetch.isEmpty { return ([:], [:]) }
|
|
|
|
var updates: [UUID: Double] = [:]
|
|
var metadataUpdates: [UUID: (String?, [String]?, Bool)] = [:]
|
|
let marketCards = cardsToFetch.filter { !$0.isCustomValuation }
|
|
let chunks = stride(from: 0, to: marketCards.count, by: 75).map { Array(marketCards[$0..<min($0 + 75, marketCards.count)]) }
|
|
|
|
for chunk in chunks {
|
|
await ScryfallThrottler.shared.wait()
|
|
let identifiers = chunk.map { PricingIdentifier(set: $0.setCode, collector_number: $0.collectorNumber) }
|
|
guard let body = try? JSONEncoder().encode(["identifiers": identifiers]) else { continue }
|
|
|
|
var request = URLRequest(url: URL(string: "https://api.scryfall.com/cards/collection")!)
|
|
request.httpMethod = "POST"
|
|
request.httpBody = body
|
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.addValue(AppConfig.scryfallUserAgent, forHTTPHeaderField: "User-Agent")
|
|
request.addValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
if let (data, response) = try? await URLSession.shared.data(for: request),
|
|
(response as? HTTPURLResponse)?.statusCode == 200,
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let dataArray = json["data"] as? [[String: Any]] {
|
|
|
|
for cardData in dataArray {
|
|
if let set = cardData["set"] as? String, let num = cardData["collector_number"] as? String,
|
|
let prices = cardData["prices"] as? [String: Any] {
|
|
|
|
let matchingCards = chunk.filter { $0.setCode.caseInsensitiveCompare(set) == .orderedSame && $0.collectorNumber == num }
|
|
for card in matchingCards {
|
|
let priceKey = getPriceKey(foilType: card.foilType, currency: currency)
|
|
|
|
// Capture Metadata (Backfill)
|
|
let rarity = cardData["rarity"] as? String
|
|
let colors = cardData["color_identity"] as? [String]
|
|
let promoTypes = cardData["promo_types"] as? [String]
|
|
let isSer = promoTypes?.contains("serialized") ?? false
|
|
metadataUpdates[card.id] = (rarity, colors, isSer)
|
|
|
|
if let newPriceStr = prices[priceKey] as? String,
|
|
let newPrice = Double(newPriceStr),
|
|
!newPrice.isNaN, !newPrice.isInfinite {
|
|
updates[card.id] = newPrice
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (force || isCacheExpired) && updateTimestamp { UserDefaults.standard.set(Date(), forKey: "LastPriceUpdate") }
|
|
return (updates, metadataUpdates)
|
|
}
|
|
|
|
static func fetchPrice(setCode: String, number: String, foilType: String, currency: CurrencyCode) async -> ScryfallData {
|
|
if !NetworkMonitor.shared.isConnected { return ScryfallData(price: nil, typeLine: "Offline", rarity: nil, colors: nil, isSerialized: false) }
|
|
await ScryfallThrottler.shared.wait()
|
|
|
|
let encodedSet = setCode.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? setCode
|
|
let encodedNum = number.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? number
|
|
|
|
guard let url = URL(string: "https://api.scryfall.com/cards/\(encodedSet)/\(encodedNum)") else { return ScryfallData(price: nil, typeLine: "Error", rarity: nil, colors: nil, isSerialized: false) }
|
|
var request = URLRequest(url: url)
|
|
request.addValue(AppConfig.scryfallUserAgent, forHTTPHeaderField: "User-Agent")
|
|
|
|
guard let (data, _) = try? await URLSession.shared.data(for: request),
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return ScryfallData(price: nil, typeLine: "Unknown", rarity: nil, colors: nil, isSerialized: false) }
|
|
|
|
let prices = json["prices"] as? [String: Any]
|
|
let priceKey = getPriceKey(foilType: foilType, currency: currency)
|
|
let price = Double(prices?[priceKey] as? String ?? "")
|
|
let rarity = json["rarity"] as? String
|
|
let colors = json["color_identity"] as? [String]
|
|
let promoTypes = json["promo_types"] as? [String]
|
|
let isSer = promoTypes?.contains("serialized") ?? false
|
|
return ScryfallData(price: price, typeLine: (json["type_line"] as? String) ?? "", rarity: rarity, colors: colors, isSerialized: isSer)
|
|
}
|
|
}
|