Major changes: - Textbook UI: chapter list, reader, and interactive exercise view (keyboard + Apple Pencil) surfaced under the Course tab. 30 chapters, 251 exercises. - Stem-change conjugation toggle on Week 4 flashcard decks (E-IE, E-I, O-UE). Uses existing VerbForm + IrregularSpan data to render highlighted present tense conjugations inline. - Deterministic on-device answer grader with partial credit (correct / close for accent-stripped or single-char-typo / wrong). 11 unit tests cover it. - SharedModels: TextbookChapter (local), TextbookExerciseAttempt (cloud- synced), AnswerGrader helpers. Bumped schema. - DataLoader: textbook seeder (version 8) + refresh helpers that preserve LanGo course decks when textbook data is re-seeded. - Local extraction pipeline in Conjuga/Scripts/textbook/ — XHTML chapter parser, answer-key parser, macOS Vision image OCR + PDF page OCR, merger, NSSpellChecker validator, language-aware auto-fixer, and repair pass that re-pairs quarantined vocab rows using bounding-box coordinates. - UI test target (ConjugaUITests) with three tests: end-to-end textbook flow, all-chapters screenshot audit, and stem-change toggle verification. Generated textbook content (textbook_data.json, textbook_vocab.json) and third-party source files are gitignored — re-run Scripts/textbook/run_pipeline.sh locally to regenerate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
188 lines
7.4 KiB
Swift
188 lines
7.4 KiB
Swift
import Foundation
|
|
import SharedModels
|
|
import SwiftData
|
|
|
|
enum StartupCoordinator {
|
|
/// First-launch work that must complete before the UI can be shown.
|
|
/// Both calls are self-gating: they return immediately if the work is already done.
|
|
@MainActor
|
|
static func bootstrap(localContainer: ModelContainer) async {
|
|
await DataLoader.seedIfNeeded(container: localContainer)
|
|
await DataLoader.refreshCourseDataIfNeeded(container: localContainer)
|
|
await DataLoader.refreshTextbookDataIfNeeded(container: localContainer)
|
|
}
|
|
|
|
/// Recurring maintenance: legacy migrations, identity repair, cloud dedup.
|
|
/// Safe to run in the background after the UI is visible.
|
|
@MainActor
|
|
static func runMaintenance(localContainer: ModelContainer, cloudContainer: ModelContainer) async {
|
|
await DataLoader.migrateCourseProgressIfNeeded(
|
|
localContainer: localContainer,
|
|
cloudContainer: cloudContainer
|
|
)
|
|
|
|
let context = cloudContainer.mainContext
|
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: context)
|
|
progress.migrateLegacyStorageIfNeeded()
|
|
if progress.enabledTenseIDs.isEmpty {
|
|
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
|
|
}
|
|
|
|
migrateLegacyPayloads(context: context)
|
|
repairIdentityFields(context: context)
|
|
dedupeCloudState(context: context)
|
|
|
|
try? context.save()
|
|
}
|
|
|
|
private static func migrateLegacyPayloads(context: ModelContext) {
|
|
let progressList = (try? context.fetch(FetchDescriptor<UserProgress>())) ?? []
|
|
for progress in progressList {
|
|
progress.migrateLegacyStorageIfNeeded()
|
|
if progress.enabledTenseIDs.isEmpty {
|
|
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
|
|
}
|
|
if progress.id.isEmpty {
|
|
progress.id = ReviewStore.progressID
|
|
}
|
|
}
|
|
|
|
let testResults = (try? context.fetch(FetchDescriptor<TestResult>())) ?? []
|
|
for result in testResults {
|
|
result.migrateLegacyStorageIfNeeded()
|
|
}
|
|
}
|
|
|
|
private static func repairIdentityFields(context: ModelContext) {
|
|
let reviewCards = (try? context.fetch(FetchDescriptor<ReviewCard>())) ?? []
|
|
for card in reviewCards {
|
|
card.refreshIdentityIfNeeded()
|
|
}
|
|
|
|
let dailyLogs = (try? context.fetch(FetchDescriptor<DailyLog>())) ?? []
|
|
for log in dailyLogs {
|
|
log.refreshIdentityIfNeeded()
|
|
}
|
|
}
|
|
|
|
private static func dedupeCloudState(context: ModelContext) {
|
|
dedupeUserProgress(context: context)
|
|
dedupeReviewCards(context: context)
|
|
dedupeCourseReviewCards(context: context)
|
|
dedupeDailyLogs(context: context)
|
|
}
|
|
|
|
private static func dedupeUserProgress(context: ModelContext) {
|
|
let all = (try? context.fetch(FetchDescriptor<UserProgress>())) ?? []
|
|
guard all.count > 1 else { return }
|
|
|
|
let ranked = all.sorted { score($0) > score($1) }
|
|
guard let canonical = ranked.first else { return }
|
|
canonical.id = ReviewStore.progressID
|
|
canonical.migrateLegacyStorageIfNeeded()
|
|
|
|
let mergedTenses = Set(all.flatMap(\.enabledTenseIDs))
|
|
let mergedBadges = Set(all.flatMap(\.unlockedBadgeIDs))
|
|
canonical.enabledTenseIDs = mergedTenses.isEmpty ? ReviewStore.defaultEnabledTenses() : mergedTenses.sorted()
|
|
canonical.unlockedBadgeIDs = mergedBadges.sorted()
|
|
canonical.totalReviewed = all.map(\.totalReviewed).max() ?? canonical.totalReviewed
|
|
canonical.longestStreak = all.map(\.longestStreak).max() ?? canonical.longestStreak
|
|
canonical.currentStreak = all.map(\.currentStreak).max() ?? canonical.currentStreak
|
|
canonical.dailyGoal = ranked.first(where: { $0.dailyGoal != 50 })?.dailyGoal ?? canonical.dailyGoal
|
|
canonical.selectedLevel = ranked.first(where: { $0.selectedLevel != VerbLevel.basic.rawValue })?.selectedLevel ?? canonical.selectedLevel
|
|
canonical.showVosotros = !all.contains(where: { !$0.showVosotros })
|
|
canonical.autoFillStem = all.contains(where: \.autoFillStem)
|
|
|
|
let latestTodayDate = all
|
|
.map(\.todayDate)
|
|
.filter { !$0.isEmpty }
|
|
.max() ?? canonical.todayDate
|
|
canonical.todayDate = latestTodayDate
|
|
canonical.todayCount = all
|
|
.filter { $0.todayDate == latestTodayDate }
|
|
.map(\.todayCount)
|
|
.max() ?? canonical.todayCount
|
|
|
|
for other in all where other !== canonical {
|
|
context.delete(other)
|
|
}
|
|
}
|
|
|
|
private static func dedupeReviewCards(context: ModelContext) {
|
|
let all = (try? context.fetch(FetchDescriptor<ReviewCard>())) ?? []
|
|
let groups = Dictionary(grouping: all, by: { ReviewCard.makeKey(verbId: $0.verbId, tenseId: $0.tenseId, personIndex: $0.personIndex) })
|
|
|
|
for (key, cards) in groups {
|
|
guard cards.count > 1 else {
|
|
cards.first?.key = key
|
|
continue
|
|
}
|
|
|
|
guard let canonical = canonicalReviewCard(from: cards) else { continue }
|
|
canonical.key = key
|
|
|
|
for other in cards where other !== canonical {
|
|
context.delete(other)
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func dedupeCourseReviewCards(context: ModelContext) {
|
|
let all = (try? context.fetch(FetchDescriptor<CourseReviewCard>())) ?? []
|
|
let groups = Dictionary(grouping: all, by: \.id)
|
|
|
|
for (id, cards) in groups where cards.count > 1 {
|
|
guard let canonical = cards.max(by: { reviewScore($0) < reviewScore($1) }) else { continue }
|
|
canonical.id = id
|
|
|
|
for other in cards where other !== canonical {
|
|
context.delete(other)
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func dedupeDailyLogs(context: ModelContext) {
|
|
let all = (try? context.fetch(FetchDescriptor<DailyLog>())) ?? []
|
|
let groups = Dictionary(grouping: all, by: \.dateString)
|
|
|
|
for (dateString, logs) in groups {
|
|
guard let canonical = logs.first else { continue }
|
|
canonical.id = DailyLog.makeID(dateString)
|
|
|
|
if logs.count == 1 { continue }
|
|
|
|
canonical.reviewCount = logs.reduce(0) { $0 + $1.reviewCount }
|
|
canonical.correctCount = logs.reduce(0) { $0 + $1.correctCount }
|
|
|
|
for other in logs.dropFirst() {
|
|
context.delete(other)
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func score(_ progress: UserProgress) -> Int {
|
|
(progress.totalReviewed * 10_000) +
|
|
(progress.longestStreak * 100) +
|
|
progress.currentStreak
|
|
}
|
|
|
|
private static func canonicalReviewCard(from cards: [ReviewCard]) -> ReviewCard? {
|
|
let latest = cards.max {
|
|
let lhsDate = $0.lastReviewDate ?? .distantPast
|
|
let rhsDate = $1.lastReviewDate ?? .distantPast
|
|
if lhsDate != rhsDate { return lhsDate < rhsDate }
|
|
if $0.repetitions != $1.repetitions { return $0.repetitions < $1.repetitions }
|
|
return $0.interval < $1.interval
|
|
}
|
|
|
|
guard let canonical = latest else { return nil }
|
|
canonical.refreshIdentityIfNeeded()
|
|
return canonical
|
|
}
|
|
|
|
private static func reviewScore(_ card: CourseReviewCard) -> Int {
|
|
let timestamp = Int(card.lastReviewDate?.timeIntervalSince1970 ?? 0)
|
|
return (timestamp * 10_000) + (card.repetitions * 100) + card.interval
|
|
}
|
|
}
|