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:
2026-03-05 12:13:17 -05:00
parent b993ef4020
commit 24dcb44af4
38 changed files with 2786 additions and 2105 deletions

View File

@@ -0,0 +1,131 @@
import Vision
import CoreML
// MARK: - ANALYSIS ACTOR (Core Card Recognition)
actor AnalysisActor {
private var database: [CardMetadata] = []
private var fingerprintCache: [UUID: VNFeaturePrintObservation] = [:]
func loadDatabase(from url: URL) throws {
let data = try Data(contentsOf: url, options: .mappedIfSafe)
let loaded = try JSONDecoder().decode([CardFingerprint].self, from: data)
self.fingerprintCache.removeAll()
self.database.removeAll()
for card in loaded {
if let obs = try? NSKeyedUnarchiver.unarchivedObject(ofClass: VNFeaturePrintObservation.self, from: card.featureData) {
self.fingerprintCache[card.id] = obs
}
self.database.append(CardMetadata(id: card.id, name: card.name, setCode: card.setCode, collectorNumber: card.collectorNumber, hasFoilPrinting: card.hasFoilPrinting, hasSerializedPrinting: card.hasSerializedPrinting ?? false, priceScanned: card.priceScanned))
}
}
func analyze(croppedImage: CGImage, orientation: CGImagePropertyOrientation) async -> (CardMetadata?, Bool) {
guard let print = try? await FeatureMatcher.generateFingerprint(from: croppedImage, orientation: orientation) else { return (nil, false) }
let result = FeatureMatcher.identify(scan: print, database: self.database, cache: self.fingerprintCache)
var resolvedCard: CardMetadata?
var detectedSerialized = false
switch result {
case .exact(let card):
resolvedCard = card
case .unknown:
return (nil, false)
case .ambiguous(_, let candidates):
let (ocrSet, ocrNum, ocrYear, isSerialized) = OCREngine.readCardDetails(image: croppedImage, orientation: orientation)
detectedSerialized = isSerialized
// Run Heuristics to resolve specific ambiguities
let isAlpha = CornerDetector.isAlphaCorner(image: croppedImage, orientation: orientation)
let saturation = SaturationDetector.analyze(image: croppedImage, orientation: orientation)
let borderColor = BorderDetector.detect(image: croppedImage, orientation: orientation)
let hasListSymbol = ListSymbolDetector.hasListSymbol(image: croppedImage, orientation: orientation)
let hasStamp = StampDetector.hasStamp(image: croppedImage, orientation: orientation)
var filtered = candidates
// 1. Alpha (LEA) vs Beta (LEB)
if candidates.contains(where: { $0.setCode == "LEA" }) && candidates.contains(where: { $0.setCode == "LEB" }) {
if isAlpha { filtered = filtered.filter { $0.setCode == "LEA" } }
else { filtered = filtered.filter { $0.setCode == "LEB" } }
}
// 2. Unlimited (2ED) vs Revised (3ED)
if candidates.contains(where: { $0.setCode == "2ED" }) && candidates.contains(where: { $0.setCode == "3ED" }) {
if saturation > 0.25 { filtered = filtered.filter { $0.setCode == "2ED" } }
else { filtered = filtered.filter { $0.setCode == "3ED" || $0.setCode == "SUM" } }
}
// 3. The List / Mystery
if hasListSymbol {
let listSets = ["PLIST", "MB1", "UPLIST", "H1R"]
let listCandidates = filtered.filter { listSets.contains($0.setCode) }
if !listCandidates.isEmpty { filtered = listCandidates }
}
// 4. World Champ (Gold Border)
if borderColor == .gold {
let wcCandidates = filtered.filter { $0.setCode.hasPrefix("WC") }
if !wcCandidates.isEmpty { filtered = wcCandidates }
}
// 5. Promo Stamps
if hasStamp {
let promoCandidates = filtered.filter { $0.setCode.lowercased().hasPrefix("p") }
if !promoCandidates.isEmpty { filtered = promoCandidates }
}
// 6. Chronicles (White Border) vs Originals (Black Border)
let chroniclesOriginals = ["ARN", "ATQ", "LEG", "DRK"]
if candidates.contains(where: { $0.setCode == "CHR" }) && candidates.contains(where: { chroniclesOriginals.contains($0.setCode) }) {
if borderColor == .white { filtered = filtered.filter { $0.setCode == "CHR" } }
else if borderColor == .black { filtered = filtered.filter { chroniclesOriginals.contains($0.setCode) } }
}
// 7. Summer Magic (Edgar) - 1994 Copyright on Revised
if let year = ocrYear, year == "1994", candidates.contains(where: { $0.setCode == "3ED" }) {
let sumCandidates = filtered.filter { $0.setCode == "SUM" }
if !sumCandidates.isEmpty { filtered = sumCandidates }
} else if candidates.contains(where: { $0.setCode == "3ED" }) {
filtered = filtered.filter { $0.setCode != "SUM" }
}
var resolved: CardMetadata?
if let set = ocrSet, let num = ocrNum, let match = filtered.first(where: { $0.setCode.uppercased() == set && $0.collectorNumber == num }) { resolved = match }
else if let set = ocrSet, let match = filtered.first(where: { $0.setCode.uppercased() == set }) { resolved = match }
else if let set = SetSymbolEngine.recognizeSet(image: croppedImage, orientation: orientation), let match = filtered.first(where: { $0.setCode.caseInsensitiveCompare(set) == .orderedSame }) { resolved = match }
else if let set = ClusterEngine.refine(candidates: filtered), let match = filtered.first(where: { $0.setCode == set }) { resolved = match }
else { resolved = filtered.first ?? candidates.first }
resolvedCard = resolved
}
guard let card = resolvedCard else { return (nil, false) }
// DB CHECK: Only run/trust OCR serialization if the card is known to have a serialized printing
if card.hasSerializedPrinting {
if case .exact = result {
let (_, _, _, isSer) = OCREngine.readCardDetails(image: croppedImage, orientation: orientation)
detectedSerialized = isSer
}
} else {
detectedSerialized = false
}
return (card, detectedSerialized)
}
}
// MARK: - CLUSTER ENGINE (Ambiguity Resolution)
class ClusterEngine {
static func refine(candidates: [CardMetadata]) -> String? {
// Weighted voting: 1st candidate = 3 pts, 2nd = 2 pts, others = 1 pt
var scores: [String: Int] = [:]
for (index, card) in candidates.prefix(5).enumerated() {
let weight = max(1, 3 - index)
scores[card.setCode, default: 0] += weight
}
return scores.sorted { $0.value > $1.value }.first?.key
}
}