Files
Spanish/Conjuga/Conjuga/Services/DataLoader.swift
Trey t fd5861c48d Move reference-data models to SharedModels to fix widget-triggered data loss
Root cause: the widget was opening the shared local.store with a 2-entity
schema (VocabCard, CourseDeck), causing SwiftData to destructively migrate
the file and drop the 4 entities the widget didn't know about (Verb,
VerbForm, IrregularSpan, TenseGuide). The main app would then re-seed on
next launch, and the cycle repeated forever.

Fix: move Verb, VerbForm, IrregularSpan, TenseGuide from the app target
into SharedModels so both the main app and the widget use the exact same
types from the same module. Both now declare all 6 local entities in their
ModelContainer, producing identical schema hashes and eliminating the
destructive migration.

Other changes bundled in this commit (accumulated during debugging):
- Split ModelContainer into localContainer + cloudContainer (no more
  CloudKit + non-CloudKit configs in one container)
- Add SharedStore.localStoreURL() helper and a global reference for
  bypass-environment fetches
- One-time store reset mechanism to wipe stale schema metadata from
  previous broken iterations
- Bootstrap/maintenance split so only seeding gates the UI; dedup and
  cloud repair run in the background
- Sync status toast that shows "Syncing" while background maintenance
  runs (network-aware, auto-dismisses)
- Background app refresh task to keep the widget word-of-day fresh
- Speaker icon on VerbDetailView for TTS
- Grammar notes navigation fix (nested NavigationStack was breaking
  detail pane on iPhone)
- Word-of-day widget swaps front/back when the deck is reversed so the
  Spanish word always shows in bold
- StoreInspector diagnostic helper for raw SQLite table inspection
- Add Conjuga scheme explicitly to project.yml so xcodegen doesn't drop it

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:51:02 -05:00

306 lines
12 KiB
Swift

import SwiftData
import SharedModels
import Foundation
actor DataLoader {
static func seedIfNeeded(container: ModelContainer) async {
let context = ModelContext(container)
let count: Int
do {
count = try context.fetchCount(FetchDescriptor<Verb>())
print("[DataLoader] seedIfNeeded: existing verb count = \(count)")
} catch {
print("[DataLoader] ⚠️ seedIfNeeded fetchCount threw: \(error)")
count = 0
}
if count > 0 { return }
print("Seeding database...")
// Try direct bundle lookup first, then subdirectory
let url = Bundle.main.url(forResource: "conjuga_data", withExtension: "json")
?? Bundle.main.url(forResource: "conjuga_data", withExtension: "json", subdirectory: "Resources")
?? Bundle.main.bundleURL.appendingPathComponent("Resources/conjuga_data.json")
guard let data = try? Data(contentsOf: url) else {
print("ERROR: Could not load conjuga_data.json from bundle at \(url)")
return
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("ERROR: Could not parse conjuga_data")
return
}
// Seed tense guides
if let guides = json["tenseGuides"] as? [[String: Any]] {
for g in guides {
guard let tenseId = g["tenseId"] as? String,
let title = g["title"] as? String,
let body = g["body"] as? String else { continue }
let guide = TenseGuide(tenseId: tenseId, title: title, body: body)
context.insert(guide)
}
}
// Seed verbs
var verbMap: [Int: Verb] = [:]
if let verbs = json["verbs"] as? [[String: Any]] {
for v in verbs {
guard let id = v["id"] as? Int,
let infinitive = v["infinitive"] as? String,
let english = v["english"] as? String,
let rank = v["rank"] as? Int,
let ending = v["ending"] as? String,
let reflexive = v["reflexive"] as? Int,
let level = v["level"] as? String else { continue }
let verb = Verb(id: id, infinitive: infinitive, english: english, rank: rank, ending: ending, reflexive: reflexive, level: level)
context.insert(verb)
verbMap[id] = verb
}
print("Inserted \(verbs.count) verbs")
}
try? context.save()
// Seed verb forms bulk insert, no relationship assignment (use verbId for queries)
let chunkSize = 20000
if let forms = json["verbForms"] as? [[String: Any]] {
for i in stride(from: 0, to: forms.count, by: chunkSize) {
autoreleasepool {
let end = min(i + chunkSize, forms.count)
for j in i..<end {
let f = forms[j]
guard let verbId = f["verbId"] as? Int,
let tenseId = f["tenseId"] as? String,
let personIndex = f["personIndex"] as? Int,
let form = f["form"] as? String,
let regularity = f["regularity"] as? String else { continue }
let vf = VerbForm(verbId: verbId, tenseId: tenseId, personIndex: personIndex, form: form, regularity: regularity)
context.insert(vf)
}
try? context.save()
}
}
print("Inserted \(forms.count) verb forms")
}
// Seed irregular spans bulk insert
if let spans = json["irregularSpans"] as? [[String: Any]] {
for i in stride(from: 0, to: spans.count, by: chunkSize) {
autoreleasepool {
let end = min(i + chunkSize, spans.count)
for j in i..<end {
let s = spans[j]
guard let verbId = s["verbId"] as? Int,
let tenseId = s["tenseId"] as? String,
let personIndex = s["personIndex"] as? Int,
let spanType = s["type"] as? Int,
let pattern = s["pattern"] as? Int,
let start = s["start"] as? Int,
let end = s["end"] as? Int else { continue }
let span = IrregularSpan(verbId: verbId, tenseId: tenseId, personIndex: personIndex, spanType: spanType, pattern: pattern, start: start, end: end)
context.insert(span)
}
try? context.save()
}
}
print("Inserted \(spans.count) irregular spans")
}
do {
try context.save()
} catch {
print("[DataLoader] 🔥 Final verb save error: \(error)")
}
print("Verb seeding complete")
// Seed course data (uses the same mainContext so @Query sees it)
seedCourseData(context: context)
}
/// Re-seed course data if the version has changed (e.g. examples were added).
/// Call this on every launch it checks a version key and only re-seeds when needed.
static func refreshCourseDataIfNeeded(container: ModelContainer) async {
let currentVersion = 3 // Bump this whenever course_data.json changes
let key = "courseDataVersion"
let shared = UserDefaults.standard
if shared.integer(forKey: key) >= currentVersion { return }
print("Course data version outdated — re-seeding...")
let context = ModelContext(container)
// Delete existing course data
try? context.delete(model: VocabCard.self)
try? context.delete(model: CourseDeck.self)
try? context.save()
// Re-seed
seedCourseData(context: context)
shared.set(currentVersion, forKey: key)
print("Course data re-seeded to version \(currentVersion)")
}
static func migrateCourseProgressIfNeeded(
localContainer: ModelContainer,
cloudContainer: ModelContainer
) async {
let migrationVersion = 2
let key = "courseProgressMigrationVersion"
let shared = UserDefaults.standard
if shared.integer(forKey: key) >= migrationVersion { return }
let localContext = ModelContext(localContainer)
let cloudContext = ModelContext(cloudContainer)
let descriptor = FetchDescriptor<VocabCard>()
let allCards = (try? localContext.fetch(descriptor)) ?? []
var migratedCount = 0
for card in allCards where hasLegacyCourseProgress(card) {
let reviewKey = CourseCardStore.reviewKey(for: card)
let reviewCard = findOrCreateCourseReviewCard(
id: reviewKey,
deckId: card.deckId,
front: card.front,
back: card.back,
context: cloudContext
)
if let reviewDate = reviewCard.lastReviewDate,
let legacyDate = card.lastReviewDate,
reviewDate >= legacyDate {
continue
}
reviewCard.easeFactor = card.easeFactor
reviewCard.interval = card.interval
reviewCard.repetitions = card.repetitions
reviewCard.dueDate = card.dueDate
reviewCard.lastReviewDate = card.lastReviewDate
migratedCount += 1
}
if migratedCount > 0 {
try? cloudContext.save()
print("Migrated \(migratedCount) course progress cards to cloud store")
}
shared.set(migrationVersion, forKey: key)
}
private static func seedCourseData(context: ModelContext) {
let url = Bundle.main.url(forResource: "course_data", withExtension: "json")
?? Bundle.main.bundleURL.appendingPathComponent("course_data.json")
guard let data = try? Data(contentsOf: url) else {
print("No course_data.json found — skipping course seeding")
return
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("ERROR: Could not parse course_data.json")
return
}
// Support both formats: {"courses": [...]} (new) and {"course": "...", "weeks": [...]} (old)
var courseList: [[String: Any]] = []
if let courses = json["courses"] as? [[String: Any]] {
courseList = courses
} else if json["weeks"] != nil {
courseList = [json]
}
var deckCount = 0
var cardCount = 0
for courseData in courseList {
guard let weeks = courseData["weeks"] as? [[String: Any]],
let courseName = courseData["course"] as? String else { continue }
let courseSlug = courseName.lowercased()
.replacingOccurrences(of: " ", with: "-")
.replacingOccurrences(of: "|", with: "")
for weekData in weeks {
guard let weekNum = weekData["week"] as? Int,
let decks = weekData["decks"] as? [[String: Any]] else { continue }
for (deckIndex, deckData) in decks.enumerated() {
guard let title = deckData["title"] as? String,
let cards = deckData["cards"] as? [Any] else { continue }
let isReversed = (deckData["isReversed"] as? Bool) ?? false
let deckId = "\(courseSlug)_w\(weekNum)_\(deckIndex)_\(isReversed ? "rev" : "fwd")"
let deck = CourseDeck(
id: deckId,
weekNumber: weekNum,
title: title,
cardCount: cards.count,
courseName: courseName,
isReversed: isReversed
)
context.insert(deck)
deckCount += 1
for rawCard in cards {
guard let cardDict = rawCard as? [String: Any],
let front = cardDict["front"] as? String,
let back = cardDict["back"] as? String else { continue }
// Parse example sentences
var exES: [String] = []
var exEN: [String] = []
if let examples = cardDict["examples"] as? [[String: String]] {
for ex in examples {
if let es = ex["es"] { exES.append(es) }
if let en = ex["en"] { exEN.append(en) }
}
}
let card = VocabCard(front: front, back: back, deckId: deckId, examplesES: exES, examplesEN: exEN)
card.deck = deck
context.insert(card)
cardCount += 1
}
}
try? context.save()
}
}
print("Course seeding complete: \(deckCount) decks, \(cardCount) cards")
}
private static func hasLegacyCourseProgress(_ card: VocabCard) -> Bool {
card.repetitions > 0 ||
card.interval > 0 ||
abs(card.easeFactor - 2.5) > 0.0001 ||
card.lastReviewDate != nil
}
private static func findOrCreateCourseReviewCard(
id: String,
deckId: String,
front: String,
back: String,
context: ModelContext
) -> CourseReviewCard {
let descriptor = FetchDescriptor<CourseReviewCard>(
predicate: #Predicate<CourseReviewCard> { $0.id == id }
)
if let existing = (try? context.fetch(descriptor))?.first {
return existing
}
let reviewCard = CourseReviewCard(id: id, deckId: deckId, front: front, back: back)
context.insert(reviewCard)
return reviewCard
}
}