Files
Spanish/Conjuga/Conjuga/Services/StartupCoordinator.swift
Trey T 63dfc5e41a Add textbook reader, exercise grading, stem-change toggle, extraction pipeline
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>
2026-04-19 15:12:55 -05:00

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
}
}