Implement storage architecture from ai_blueprint.md
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>
This commit is contained in:
121
IYmtg_App_iOS/Data/Network/ScryfallAPI.swift
Normal file
121
IYmtg_App_iOS/Data/Network/ScryfallAPI.swift
Normal file
@@ -0,0 +1,121 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user