diff --git a/Conjuga/Conjuga/Services/DataLoader.swift b/Conjuga/Conjuga/Services/DataLoader.swift index b7808c7..31de5b6 100644 --- a/Conjuga/Conjuga/Services/DataLoader.swift +++ b/Conjuga/Conjuga/Services/DataLoader.swift @@ -6,7 +6,7 @@ actor DataLoader { static let courseDataVersion = 7 static let courseDataKey = "courseDataVersion" - static let textbookDataVersion = 11 + static let textbookDataVersion = 12 static let textbookDataKey = "textbookDataVersion" /// Quick check: does the DB need seeding or course data refresh? @@ -148,13 +148,24 @@ actor DataLoader { } } - /// Re-seed textbook data if the version has changed. + /// Re-seed textbook data if the version has changed OR if the rows are + /// missing on disk. The row-count check exists because anything opening + /// this store with a subset schema (e.g. an out-of-date widget extension) + /// can destructively drop the rows without touching UserDefaults — so a + /// pure version-flag trigger would leave us permanently empty. static func refreshTextbookDataIfNeeded(container: ModelContainer) async { let shared = UserDefaults.standard - if shared.integer(forKey: textbookDataKey) >= textbookDataVersion { return } - - print("Textbook data version outdated — re-seeding...") let context = ModelContext(container) + let existingCount = (try? context.fetchCount(FetchDescriptor())) ?? 0 + let versionCurrent = shared.integer(forKey: textbookDataKey) >= textbookDataVersion + + if versionCurrent && existingCount > 0 { return } + + if versionCurrent { + print("Textbook data version current but store has \(existingCount) chapters — re-seeding...") + } else { + print("Textbook data version outdated — re-seeding...") + } // Fetch + delete individually instead of batch delete. SwiftData's // context.delete(model:) hits the store directly and doesn't always diff --git a/Conjuga/Conjuga/Services/StoreInspector.swift b/Conjuga/Conjuga/Services/StoreInspector.swift index 6061d13..2ef6c61 100644 --- a/Conjuga/Conjuga/Services/StoreInspector.swift +++ b/Conjuga/Conjuga/Services/StoreInspector.swift @@ -26,12 +26,14 @@ enum StoreInspector { let hasZVERBFORM = tables.contains("ZVERBFORM") let hasZTENSEGUIDE = tables.contains("ZTENSEGUIDE") let hasZVOCABCARD = tables.contains("ZVOCABCARD") + let hasZTEXTBOOKCHAPTER = tables.contains("ZTEXTBOOKCHAPTER") var summary = "[StoreInspector:\(label)] \(tables.count) tables" summary += " | ZVERB=\(hasZVERB ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERB") : -1)" summary += " ZVERBFORM=\(hasZVERBFORM ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERBFORM") : -1)" summary += " ZTENSEGUIDE=\(hasZTENSEGUIDE ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZTENSEGUIDE") : -1)" summary += " ZVOCABCARD=\(hasZVOCABCARD ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVOCABCARD") : -1)" + summary += " ZTEXTBOOKCHAPTER=\(hasZTEXTBOOKCHAPTER ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZTEXTBOOKCHAPTER") : -1)" print(summary) // Log all Z-tables (SwiftData entity tables start with Z, minus Core Data system tables) diff --git a/Conjuga/ConjugaWidget/CombinedWidget.swift b/Conjuga/ConjugaWidget/CombinedWidget.swift index 70c6725..ef24ef3 100644 --- a/Conjuga/ConjugaWidget/CombinedWidget.swift +++ b/Conjuga/ConjugaWidget/CombinedWidget.swift @@ -41,14 +41,16 @@ struct CombinedProvider: TimelineProvider { private func fetchWordOfDay(for date: Date) -> WordOfDay? { guard let localURL = SharedStore.localStoreURL() else { return nil } - // MUST declare all 6 local entities to match the main app's schema. - // Declaring a subset would cause SwiftData to destructively migrate the store - // on open, dropping the entities not listed here. + // MUST declare all 7 local entities to match the main app's schema. + // Declaring a subset would cause SwiftData to destructively migrate the + // store on open, dropping the entities not listed here (this is how we + // previously lost all TextbookChapter rows on every widget refresh). let config = ModelConfiguration( "local", schema: Schema([ Verb.self, VerbForm.self, IrregularSpan.self, TenseGuide.self, CourseDeck.self, VocabCard.self, + TextbookChapter.self, ]), url: localURL, cloudKitDatabase: .none @@ -56,6 +58,7 @@ struct CombinedProvider: TimelineProvider { guard let container = try? ModelContainer( for: Verb.self, VerbForm.self, IrregularSpan.self, TenseGuide.self, CourseDeck.self, VocabCard.self, + TextbookChapter.self, configurations: config ) else { return nil } diff --git a/Conjuga/ConjugaWidget/WordOfDayWidget.swift b/Conjuga/ConjugaWidget/WordOfDayWidget.swift index e97530e..c41c3be 100644 --- a/Conjuga/ConjugaWidget/WordOfDayWidget.swift +++ b/Conjuga/ConjugaWidget/WordOfDayWidget.swift @@ -32,14 +32,16 @@ struct WordOfDayProvider: TimelineProvider { private func fetchWordOfDay(for date: Date) -> WordOfDay? { guard let localURL = SharedStore.localStoreURL() else { return nil } - // MUST declare all 6 local entities to match the main app's schema. - // Declaring a subset would cause SwiftData to destructively migrate the store - // on open, dropping the entities not listed here. + // MUST declare all 7 local entities to match the main app's schema. + // Declaring a subset would cause SwiftData to destructively migrate the + // store on open, dropping the entities not listed here (this is how we + // previously lost all TextbookChapter rows on every widget refresh). let config = ModelConfiguration( "local", schema: Schema([ Verb.self, VerbForm.self, IrregularSpan.self, TenseGuide.self, CourseDeck.self, VocabCard.self, + TextbookChapter.self, ]), url: localURL, cloudKitDatabase: .none @@ -47,6 +49,7 @@ struct WordOfDayProvider: TimelineProvider { guard let container = try? ModelContainer( for: Verb.self, VerbForm.self, IrregularSpan.self, TenseGuide.self, CourseDeck.self, VocabCard.self, + TextbookChapter.self, configurations: config ) else { return nil }