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>
306 lines
12 KiB
Swift
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
|
|
}
|
|
}
|