Scraped a 4h Spanish fundamentals YouTube video (transcript + OCR on 14810 frames), extracted structured content across 52 chapters, and generated fill-in-the-blank quizzes for every grammar topic. - 13 new GrammarNote entries (articles, possessives, demonstratives, greetings, poder, al/del, prepositional pronouns, irregular yo, stem-changing, stressed possessives, present/future perfect, present indicative conjugation) - 1010 generated exercises across all 36 grammar notes (new + existing) - Fix tense guide parser to handle unnumbered *Usages* blocks - Rewrite 6 broken tense guide bodies (imperative, subj pluperfect, subj future) with numbered usage format - Bump courseDataVersion 5→6 with TenseGuide refresh on upgrade - Add docs/spanish-fundamentals/ with raw transcripts, polished notes, structured JSON, and exercise data Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
575 lines
20 KiB
Swift
575 lines
20 KiB
Swift
import SwiftUI
|
||
import SwiftData
|
||
import SharedModels
|
||
|
||
struct GuideView: View {
|
||
@Environment(\.modelContext) private var modelContext
|
||
|
||
@State private var guides: [TenseGuide] = []
|
||
@State private var selectedGuide: TenseGuide?
|
||
@State private var selectedNote: GrammarNote?
|
||
@State private var selectedTab: GuideTab = .tenses
|
||
|
||
enum GuideTab: String, CaseIterable {
|
||
case tenses = "Tenses"
|
||
case grammar = "Grammar"
|
||
}
|
||
|
||
private var guideMap: [String: TenseGuide] {
|
||
Dictionary(uniqueKeysWithValues: guides.map { ($0.tenseId, $0) })
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationSplitView {
|
||
VStack(spacing: 0) {
|
||
Picker("Guide", selection: $selectedTab) {
|
||
ForEach(GuideTab.allCases, id: \.self) { tab in
|
||
Text(tab.rawValue).tag(tab)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.padding(.horizontal)
|
||
.padding(.vertical, 8)
|
||
|
||
switch selectedTab {
|
||
case .tenses:
|
||
tensesList
|
||
case .grammar:
|
||
grammarList
|
||
}
|
||
}
|
||
.navigationTitle("Guide")
|
||
.task { loadGuides() }
|
||
.onAppear(perform: loadGuides)
|
||
.onChange(of: selectedTab) { _, _ in
|
||
selectedGuide = nil
|
||
selectedNote = nil
|
||
}
|
||
} detail: {
|
||
if let guide = selectedGuide {
|
||
GuideDetailView(guide: guide)
|
||
} else if let note = selectedNote {
|
||
GrammarNoteDetailView(note: note)
|
||
} else {
|
||
ContentUnavailableView("Select a Topic", systemImage: "book", description: Text("Choose a tense or grammar topic to learn more."))
|
||
}
|
||
}
|
||
}
|
||
|
||
private var tensesList: some View {
|
||
List(selection: $selectedGuide) {
|
||
ForEach(TenseInfo.byMood(), id: \.0) { mood, tenses in
|
||
Section(mood) {
|
||
ForEach(tenses) { tense in
|
||
if let guide = guideMap[tense.id] {
|
||
NavigationLink(value: guide) {
|
||
TenseRowView(tense: tense)
|
||
}
|
||
} else {
|
||
TenseRowView(tense: tense)
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var grammarList: some View {
|
||
GrammarNotesListView(selectedNote: $selectedNote)
|
||
}
|
||
|
||
private func loadGuides() {
|
||
// Hit the shared local container directly, bypassing @Environment.
|
||
guard let container = SharedStore.localContainer else {
|
||
print("[GuideView] ⚠️ SharedStore.localContainer is nil")
|
||
return
|
||
}
|
||
let context = ModelContext(container)
|
||
guides = ReferenceStore(context: context).fetchGuides()
|
||
print("[GuideView] loaded \(guides.count) tense guides (container: \(ObjectIdentifier(container)))")
|
||
if selectedGuide == nil {
|
||
selectedGuide = guides.first
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Tense Row
|
||
|
||
private struct TenseRowView: View {
|
||
let tense: TenseInfo
|
||
|
||
var body: some View {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(tense.english)
|
||
.font(.headline)
|
||
Text(tense.spanish)
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
Spacer()
|
||
|
||
if tense.isCore {
|
||
Text("Essential")
|
||
.font(.caption2.weight(.semibold))
|
||
.foregroundStyle(.orange)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 3)
|
||
.background(.orange.opacity(0.12), in: Capsule())
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Guide Detail
|
||
|
||
struct GuideDetailView: View {
|
||
let guide: TenseGuide
|
||
|
||
private var tenseInfo: TenseInfo? {
|
||
TenseInfo.find(guide.tenseId)
|
||
}
|
||
|
||
private var endingTable: TenseEndingTable? {
|
||
TenseEndingTable.find(guide.tenseId)
|
||
}
|
||
|
||
private var parsedContent: GuideContent {
|
||
GuideContent.parse(guide.body)
|
||
}
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 24) {
|
||
// Header
|
||
headerSection
|
||
|
||
// Conjugation ending table
|
||
if let table = endingTable {
|
||
conjugationTableSection(table)
|
||
}
|
||
|
||
// Formula
|
||
if let table = endingTable {
|
||
formulaSection(table)
|
||
}
|
||
|
||
Divider()
|
||
|
||
// Description
|
||
if !parsedContent.description.isEmpty {
|
||
descriptionSection
|
||
}
|
||
|
||
// Consolidated usages card
|
||
if !parsedContent.usages.isEmpty {
|
||
usagesSummarySection
|
||
}
|
||
|
||
// Examples section
|
||
if parsedContent.usages.contains(where: { !$0.examples.isEmpty }) {
|
||
examplesSection
|
||
}
|
||
}
|
||
.padding()
|
||
.adaptiveContainer(maxWidth: 800)
|
||
}
|
||
.navigationTitle(tenseInfo?.english ?? guide.title)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
}
|
||
|
||
// MARK: - Header
|
||
|
||
private var headerSection: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
if let info = tenseInfo {
|
||
Text(info.spanish)
|
||
.font(.title2.weight(.bold))
|
||
Text(info.english)
|
||
.font(.title3)
|
||
.foregroundStyle(.secondary)
|
||
HStack {
|
||
Label(info.mood, systemImage: "tag")
|
||
.font(.caption.weight(.medium))
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 4)
|
||
.background(.fill.tertiary, in: Capsule())
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Conjugation Table
|
||
|
||
private func conjugationTableSection(_ table: TenseEndingTable) -> some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Conjugation Endings")
|
||
.font(.headline)
|
||
|
||
if table.isCompound, let aux = table.auxiliaryForms {
|
||
// Compound tense: show auxiliary + participle
|
||
compoundTenseTable(table, auxiliary: aux)
|
||
} else {
|
||
// Simple tense: show endings grid
|
||
simpleEndingsGrid(table)
|
||
}
|
||
}
|
||
.padding()
|
||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
|
||
}
|
||
|
||
private func simpleEndingsGrid(_ table: TenseEndingTable) -> some View {
|
||
VStack(spacing: 0) {
|
||
// Header row
|
||
HStack(spacing: 0) {
|
||
Text("")
|
||
.frame(width: 80, alignment: .leading)
|
||
Text("~ar")
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundStyle(.orange)
|
||
.frame(maxWidth: .infinity)
|
||
Text("~er")
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundStyle(.green)
|
||
.frame(maxWidth: .infinity)
|
||
Text("~ir")
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundStyle(.blue)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.padding(.vertical, 6)
|
||
.background(.fill.quaternary)
|
||
|
||
// Example verb names
|
||
HStack(spacing: 0) {
|
||
Text("")
|
||
.frame(width: 80, alignment: .leading)
|
||
Text(table.arExample.verb)
|
||
.font(.caption2.italic())
|
||
.foregroundStyle(.secondary)
|
||
.frame(maxWidth: .infinity)
|
||
Text(table.erExample.verb)
|
||
.font(.caption2.italic())
|
||
.foregroundStyle(.secondary)
|
||
.frame(maxWidth: .infinity)
|
||
Text(table.irExample.verb)
|
||
.font(.caption2.italic())
|
||
.foregroundStyle(.secondary)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.padding(.vertical, 4)
|
||
|
||
Divider()
|
||
|
||
// Person rows
|
||
ForEach(0..<6, id: \.self) { i in
|
||
HStack(spacing: 0) {
|
||
Text(TenseEndingTable.persons[i])
|
||
.font(.subheadline.weight(.medium))
|
||
.frame(width: 80, alignment: .leading)
|
||
|
||
endingCell(table.arExample.stem, ending: table.arEndings[i], color: .orange)
|
||
.frame(maxWidth: .infinity)
|
||
endingCell(table.erExample.stem, ending: table.erEndings[i], color: .green)
|
||
.frame(maxWidth: .infinity)
|
||
endingCell(table.irExample.stem, ending: table.irEndings[i], color: .blue)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.padding(.vertical, 5)
|
||
|
||
if i < 5 {
|
||
Divider().opacity(0.5)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func endingCell(_ stem: String, ending: String, color: Color) -> some View {
|
||
Group {
|
||
if ending == "—" {
|
||
Text("—")
|
||
.font(.subheadline)
|
||
.foregroundStyle(.tertiary)
|
||
} else {
|
||
Text(
|
||
"\(Text(stem).foregroundStyle(.primary))\(Text(ending).foregroundStyle(color).fontWeight(.semibold))"
|
||
)
|
||
.font(.subheadline)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func compoundTenseTable(_ table: TenseEndingTable, auxiliary: [String]) -> some View {
|
||
VStack(spacing: 0) {
|
||
// Header
|
||
HStack(spacing: 0) {
|
||
Text("")
|
||
.frame(width: 80, alignment: .leading)
|
||
Text("Auxiliary")
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundStyle(.purple)
|
||
.frame(maxWidth: .infinity)
|
||
Text("~ar")
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundStyle(.orange)
|
||
.frame(maxWidth: .infinity)
|
||
Text("~er/~ir")
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundStyle(.cyan)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.padding(.vertical, 6)
|
||
.background(.fill.quaternary)
|
||
|
||
Divider()
|
||
|
||
ForEach(0..<6, id: \.self) { i in
|
||
HStack(spacing: 0) {
|
||
Text(TenseEndingTable.persons[i])
|
||
.font(.subheadline.weight(.medium))
|
||
.frame(width: 80, alignment: .leading)
|
||
|
||
Text(auxiliary[i])
|
||
.font(.subheadline)
|
||
.foregroundStyle(.purple)
|
||
.frame(maxWidth: .infinity)
|
||
|
||
Text(table.arExample.stem + "ado")
|
||
.font(.subheadline)
|
||
.foregroundStyle(.orange)
|
||
.frame(maxWidth: .infinity)
|
||
|
||
Text(table.erExample.stem + "ido")
|
||
.font(.subheadline)
|
||
.foregroundStyle(.cyan)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.padding(.vertical, 5)
|
||
|
||
if i < 5 {
|
||
Divider().opacity(0.5)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Formula
|
||
|
||
private func formulaSection(_ table: TenseEndingTable) -> some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Label("How to form", systemImage: "function")
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundStyle(.secondary)
|
||
|
||
Text(table.formula)
|
||
.font(.body.weight(.medium))
|
||
|
||
Text(table.stemRule)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.padding()
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||
}
|
||
|
||
// MARK: - Description
|
||
|
||
private var descriptionSection: some View {
|
||
Text(parsedContent.description)
|
||
.font(.body)
|
||
.lineSpacing(4)
|
||
}
|
||
|
||
// MARK: - Usages Summary (consolidated)
|
||
|
||
private var usagesSummarySection: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Label("When to use", systemImage: "lightbulb")
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundStyle(.secondary)
|
||
|
||
ForEach(parsedContent.usages) { usage in
|
||
HStack(alignment: .top, spacing: 10) {
|
||
Text("\(usage.number).")
|
||
.font(.subheadline.weight(.bold))
|
||
.foregroundStyle(.blue)
|
||
.frame(width: 20, alignment: .trailing)
|
||
|
||
Text(usage.title)
|
||
.font(.subheadline)
|
||
}
|
||
}
|
||
}
|
||
.padding()
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||
}
|
||
|
||
// MARK: - Examples Section
|
||
|
||
private var examplesSection: some View {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
Label("Examples", systemImage: "text.quote")
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundStyle(.secondary)
|
||
|
||
ForEach(parsedContent.usages) { usage in
|
||
ForEach(usage.examples) { example in
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(example.spanish)
|
||
.font(.body.weight(.medium))
|
||
.italic()
|
||
|
||
Text(example.english)
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Content Parsing
|
||
|
||
struct GuideContent {
|
||
let description: String
|
||
let usages: [GuideUsage]
|
||
|
||
static func parse(_ body: String) -> GuideContent {
|
||
let lines = body.components(separatedBy: "\n")
|
||
var description = ""
|
||
var usages: [GuideUsage] = []
|
||
var currentUsageTitle = ""
|
||
var currentUsageNumber = 0
|
||
var currentExamples: [GuideExample] = []
|
||
var inUsages = false
|
||
var spanishLine: String?
|
||
|
||
func flushUsage() {
|
||
// Only emit a usage if it has at least one example. This suppresses
|
||
// the implicit "Usage 1" seeded when we enter an unnumbered
|
||
// *Usages* block but the body actually has numbered headers below.
|
||
if currentUsageNumber > 0 && !currentExamples.isEmpty {
|
||
usages.append(GuideUsage(
|
||
number: currentUsageNumber,
|
||
title: currentUsageTitle,
|
||
examples: currentExamples
|
||
))
|
||
}
|
||
currentExamples = []
|
||
}
|
||
|
||
for line in lines {
|
||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||
|
||
// Skip empty lines
|
||
if trimmed.isEmpty {
|
||
if let sp = spanishLine {
|
||
// Spanish line with no English translation
|
||
currentExamples.append(GuideExample(spanish: sp, english: ""))
|
||
spanishLine = nil
|
||
}
|
||
continue
|
||
}
|
||
|
||
// Detect usage headers like "*1 Current actions*" or "**1 Current actions**"
|
||
let usagePattern = /^\*{1,2}(\d+)\s+(.+?)\*{0,2}$/
|
||
if let match = trimmed.firstMatch(of: usagePattern) {
|
||
flushUsage()
|
||
inUsages = true
|
||
currentUsageNumber = Int(match.1) ?? 0
|
||
currentUsageTitle = String(match.2)
|
||
.replacingOccurrences(of: "**", with: "")
|
||
.replacingOccurrences(of: "*", with: "")
|
||
continue
|
||
}
|
||
|
||
// Detect usage headers without numbers
|
||
let usageTitlePattern = /^\*{1,2}(.+?)\*{0,2}$/
|
||
if !inUsages, let match = trimmed.firstMatch(of: usageTitlePattern) {
|
||
let title = String(match.1).replacingOccurrences(of: "*", with: "")
|
||
if title.lowercased().contains("usage") {
|
||
inUsages = true
|
||
// Seed an implicit Usage 1 so content that follows without a
|
||
// numbered "*1 Title*" header still gets captured. Any numbered
|
||
// header below will replace this via flushUsage().
|
||
currentUsageNumber = 1
|
||
currentUsageTitle = "Usage"
|
||
continue
|
||
}
|
||
}
|
||
|
||
if !inUsages {
|
||
// Before usages section, accumulate description
|
||
let cleanLine = trimmed
|
||
.replacingOccurrences(of: "**", with: "")
|
||
.replacingOccurrences(of: "*", with: "")
|
||
if !description.isEmpty { description += " " }
|
||
description += cleanLine
|
||
continue
|
||
}
|
||
|
||
// In usages: detect example pairs (Spanish line followed by English line)
|
||
if let sp = spanishLine {
|
||
// This line is the English translation of the previous Spanish line
|
||
currentExamples.append(GuideExample(spanish: sp, english: trimmed))
|
||
spanishLine = nil
|
||
} else {
|
||
// Check if this looks like a Spanish sentence
|
||
let startsWithDash = trimmed.hasPrefix("-") || trimmed.hasPrefix("–")
|
||
let cleanLine = startsWithDash ? String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces) : trimmed
|
||
|
||
// Heuristic: Spanish sentences contain accented chars or start with ¿/¡
|
||
let isSpanish = cleanLine.contains("á") || cleanLine.contains("é") || cleanLine.contains("í") ||
|
||
cleanLine.contains("ó") || cleanLine.contains("ú") || cleanLine.contains("ñ") ||
|
||
cleanLine.hasPrefix("¿") || cleanLine.hasPrefix("¡") ||
|
||
cleanLine.first?.isUppercase == true
|
||
|
||
if isSpanish && !cleanLine.lowercased().hasPrefix("i ") && !cleanLine.lowercased().hasPrefix("the ") &&
|
||
!cleanLine.lowercased().hasPrefix("all ") && !cleanLine.lowercased().hasPrefix("when ") &&
|
||
!cleanLine.lowercased().hasPrefix("what ") {
|
||
spanishLine = cleanLine
|
||
} else {
|
||
// English or context line
|
||
if currentUsageNumber == 0 && usages.isEmpty {
|
||
// Still description
|
||
if !description.isEmpty { description += " " }
|
||
description += cleanLine
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
flushUsage()
|
||
|
||
// If no usages were found, create one from the whole body
|
||
if usages.isEmpty && !body.isEmpty {
|
||
usages.append(GuideUsage(number: 1, title: "Usage", examples: []))
|
||
}
|
||
|
||
return GuideContent(description: description, usages: usages)
|
||
}
|
||
}
|
||
|
||
struct GuideUsage: Identifiable {
|
||
let number: Int
|
||
let title: String
|
||
let examples: [GuideExample]
|
||
var id: Int { number }
|
||
}
|
||
|
||
struct GuideExample: Identifiable {
|
||
let spanish: String
|
||
let english: String
|
||
var id: String { spanish }
|
||
}
|
||
|
||
#Preview {
|
||
GuideView()
|
||
.modelContainer(for: TenseGuide.self, inMemory: true)
|
||
}
|