Files
Spanish/Conjuga/Conjuga/Views/Guide/GrammarNotesView.swift
T
Trey t 47a7871c38 Add 13 new grammar notes with 1010 exercises from video extraction
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>
2026-04-16 08:40:05 -05:00

556 lines
20 KiB
Swift

import SwiftUI
/// Standalone grammar notes view with its own NavigationStack (used outside GuideView).
struct GrammarNotesView: View {
@State private var selectedNote: GrammarNote?
var body: some View {
NavigationStack {
GrammarNotesListView(selectedNote: $selectedNote)
.navigationDestination(for: GrammarNote.self) { note in
GrammarNoteDetailView(note: note)
}
}
}
}
/// List of grammar notes that works inside a NavigationSplitView via a selection binding.
struct GrammarNotesListView: View {
@Binding var selectedNote: GrammarNote?
private var groupedNotes: [(String, [GrammarNote])] {
let grouped = Dictionary(grouping: GrammarNote.allNotesIncludingGenerated, by: \.category)
var seen: [String] = []
for note in GrammarNote.allNotesIncludingGenerated {
if !seen.contains(note.category) {
seen.append(note.category)
}
}
return seen.compactMap { cat in
guard let notes = grouped[cat] else { return nil }
return (cat, notes)
}
}
var body: some View {
List(selection: $selectedNote) {
ForEach(groupedNotes, id: \.0) { category, notes in
Section(category) {
ForEach(notes) { note in
NavigationLink(value: note) {
GrammarNoteRow(note: note)
}
}
}
}
}
}
}
// MARK: - Row
private struct GrammarNoteRow: View {
let note: GrammarNote
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(note.title)
.font(.headline)
Text(note.category)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Detail
struct GrammarNoteDetailView: View {
let note: GrammarNote
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Title header
VStack(alignment: .leading, spacing: 6) {
Text(note.title)
.font(.title.bold())
Text(note.category)
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
Divider()
// Parsed body
FormattedGrammarBody(content: note.body)
// Practice button (if exercises exist for this note)
if !GrammarExercise.exercises(for: note.id).isEmpty {
NavigationLink {
GrammarExerciseView(noteId: note.id, noteTitle: note.title)
} label: {
Label("Practice This", systemImage: "pencil.and.list.clipboard")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.purple)
}
}
.padding()
.adaptiveContainer(maxWidth: 800)
}
.navigationTitle(note.title)
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: - Formatted Body
private struct FormattedGrammarBody: View {
let content: String
private var sections: [GrammarSection] {
GrammarSection.group(GrammarLine.parse(content))
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
ForEach(sections) { section in
if section.heading == nil {
// Intro content before the first heading no card
renderLines(section.lines)
} else {
// Headed section in a card
VStack(alignment: .leading, spacing: 10) {
Text(section.heading!)
.font(.headline)
renderLines(section.lines)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
}
}
@ViewBuilder
private func renderLines(_ lines: [GrammarLine]) -> some View {
let blocks = ContentBlock.group(lines)
VStack(alignment: .leading, spacing: 12) {
ForEach(blocks) { block in
switch block.kind {
case .standalone(let line):
renderSingleLine(line)
case .exampleCard(let header, let examples):
renderExampleCard(header: header, examples: examples)
case .suffixCard(let suffix, let description, let examples):
renderSuffixCard(suffix: suffix, description: description, examples: examples)
}
}
}
}
@ViewBuilder
private func renderSingleLine(_ line: GrammarLine) -> some View {
switch line.kind {
case .paragraph(let text):
renderParagraph(text)
case .spanishExample(let spanish):
Text(spanish)
.font(.body.weight(.medium))
.italic()
.padding(.leading, 12)
case .examplePair(let spanish, let english):
VStack(alignment: .leading, spacing: 3) {
Text(spanish)
.font(.body.weight(.medium))
.italic()
Text(english)
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.leading, 12)
case .heading, .suffixDef:
EmptyView()
}
}
@ViewBuilder
private func renderExampleCard(header: GrammarLine?, examples: [GrammarLine]) -> some View {
VStack(alignment: .leading, spacing: 8) {
if let header, case .paragraph(let text) = header.kind {
renderParagraph(text)
}
VStack(alignment: .leading, spacing: 6) {
ForEach(examples) { ex in
if case .examplePair(let spanish, let english) = ex.kind {
VStack(alignment: .leading, spacing: 2) {
Text(spanish)
.font(.body.weight(.medium))
.italic()
Text(english)
.font(.callout)
.foregroundStyle(.secondary)
}
} else if case .spanishExample(let spanish) = ex.kind {
Text(spanish)
.font(.body.weight(.medium))
.italic()
}
}
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 10))
}
@ViewBuilder
private func renderSuffixCard(suffix: String, description: String, examples: [GrammarLine]) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(suffix)
.font(.subheadline.weight(.bold).monospaced())
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.tint.opacity(0.12), in: Capsule())
Text(description)
.font(.callout)
.foregroundStyle(.secondary)
}
if !examples.isEmpty {
VStack(alignment: .leading, spacing: 4) {
ForEach(examples) { ex in
if case .examplePair(let spanish, let english) = ex.kind {
HStack(spacing: 0) {
Text(spanish)
.font(.body.weight(.medium))
.italic()
Text(" ")
Text(english)
.font(.callout)
.foregroundStyle(.secondary)
}
} else if case .spanishExample(let spanish) = ex.kind {
Text(spanish)
.font(.body.weight(.medium))
.italic()
}
}
}
.padding(.leading, 8)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 10))
}
@ViewBuilder
private func renderParagraph(_ text: String) -> some View {
Text(parseInlineFormatting(text))
.font(.body)
.lineSpacing(4)
}
private func parseInlineFormatting(_ text: String) -> AttributedString {
var result = AttributedString()
var remaining = text[text.startIndex...]
while !remaining.isEmpty {
// Look for *italic* or **bold** markers
if let starRange = remaining.range(of: "*") {
// Add text before the star
let before = remaining[remaining.startIndex..<starRange.lowerBound]
if !before.isEmpty {
result.append(AttributedString(String(before)))
}
let afterStar = remaining[starRange.upperBound...]
// Check for **bold**
if afterStar.hasPrefix("*") {
let boldStart = afterStar.index(after: afterStar.startIndex)
if let endBold = afterStar[boldStart...].range(of: "**") {
let boldText = String(afterStar[boldStart..<endBold.lowerBound])
var attr = AttributedString(boldText)
attr.font = .body.bold()
result.append(attr)
remaining = afterStar[endBold.upperBound...]
continue
}
}
// Single *italic*
if let endItalic = afterStar.range(of: "*") {
let italicText = String(afterStar[afterStar.startIndex..<endItalic.lowerBound])
var attr = AttributedString(italicText)
attr.font = .body.italic()
result.append(attr)
remaining = afterStar[endItalic.upperBound...]
continue
}
// No closing star found, just add the star literally
result.append(AttributedString("*"))
remaining = afterStar
} else {
// No more stars, add the rest
result.append(AttributedString(String(remaining)))
break
}
}
return result
}
}
// MARK: - Grammar Line Parsing
private struct GrammarLine: Identifiable {
let id: Int
let kind: Kind
var isExample: Bool {
switch kind {
case .examplePair, .spanishExample: return true
default: return false
}
}
enum Kind {
case paragraph(String)
case heading(String)
case spanishExample(String)
case examplePair(spanish: String, english: String)
/// A suffix definition line like `*-ito / -ita* description`
case suffixDef(suffix: String, description: String)
}
static func parse(_ body: String) -> [GrammarLine] {
let rawLines = body.components(separatedBy: "\n")
var result: [GrammarLine] = []
var index = 0
var paragraphBuffer = ""
func flushParagraph() {
let trimmed = paragraphBuffer.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
result.append(GrammarLine(id: result.count, kind: .paragraph(trimmed)))
}
paragraphBuffer = ""
}
while index < rawLines.count {
let line = rawLines[index].trimmingCharacters(in: .whitespaces)
// Empty line: flush paragraph
if line.isEmpty {
flushParagraph()
index += 1
continue
}
// Heading: **text** on its own line that doesn't contain example patterns
if line.hasPrefix("**") && line.hasSuffix("**") && !line.contains("*—*") && !line.contains("") {
flushParagraph()
let heading = line
.replacingOccurrences(of: "**", with: "")
.trimmingCharacters(in: .whitespaces)
result.append(GrammarLine(id: result.count, kind: .heading(heading)))
index += 1
continue
}
// Example line: starts with * and contains Spanish (italic example)
// Pattern: *Spanish sentence.* English translation.
if line.hasPrefix("*") && !line.hasPrefix("**") {
flushParagraph()
// Check for pattern: *spanish* english
if let dashRange = line.range(of: "") ?? line.range(of: " -- ") {
let spanishPart = String(line[line.startIndex..<dashRange.lowerBound])
.replacingOccurrences(of: "*", with: "")
.trimmingCharacters(in: .whitespaces)
let englishPart = String(line[dashRange.upperBound...])
.replacingOccurrences(of: "*", with: "")
.trimmingCharacters(in: .whitespaces)
// Detect suffix definitions: spanish part starts with "-"
if spanishPart.hasPrefix("-") {
result.append(GrammarLine(id: result.count, kind: .suffixDef(suffix: spanishPart, description: englishPart)))
} else {
result.append(GrammarLine(id: result.count, kind: .examplePair(spanish: spanishPart, english: englishPart)))
}
} else {
// Just a Spanish example without translation
let spanish = line.replacingOccurrences(of: "*", with: "")
.trimmingCharacters(in: .whitespaces)
result.append(GrammarLine(id: result.count, kind: .spanishExample(spanish)))
}
index += 1
continue
}
// Regular text: accumulate into paragraph
if !paragraphBuffer.isEmpty {
paragraphBuffer += " "
}
paragraphBuffer += line
index += 1
}
flushParagraph()
return result
}
}
// MARK: - Content Block Grouping
/// Groups paragraphs with their trailing examples into visual cards.
/// Also handles suffix definitions as a special card type.
private struct ContentBlock: Identifiable {
let id: Int
enum Kind {
/// A standalone line (paragraph with no examples, or orphaned example)
case standalone(GrammarLine)
/// A paragraph header followed by example pairs rendered as a card
case exampleCard(header: GrammarLine?, examples: [GrammarLine])
/// A suffix definition with its examples rendered as a pill card
case suffixCard(suffix: String, description: String, examples: [GrammarLine])
}
let kind: Kind
static func group(_ lines: [GrammarLine]) -> [ContentBlock] {
var result: [ContentBlock] = []
var i = 0
while i < lines.count {
let line = lines[i]
// Suffix definition: collect trailing examples
if case .suffixDef(let suffix, let desc) = line.kind {
var examples: [GrammarLine] = []
var j = i + 1
while j < lines.count, lines[j].isExample {
examples.append(lines[j])
j += 1
}
result.append(ContentBlock(id: result.count, kind: .suffixCard(suffix: suffix, description: desc, examples: examples)))
i = j
continue
}
// Paragraph followed by examples: group into a card
if case .paragraph = line.kind {
var j = i + 1
// Check if examples follow
if j < lines.count, lines[j].isExample {
var examples: [GrammarLine] = []
while j < lines.count, lines[j].isExample {
examples.append(lines[j])
j += 1
}
result.append(ContentBlock(id: result.count, kind: .exampleCard(header: line, examples: examples)))
i = j
} else {
// Standalone paragraph
result.append(ContentBlock(id: result.count, kind: .standalone(line)))
i += 1
}
continue
}
// Orphaned examples (no preceding paragraph) group into a card
if line.isExample {
var examples: [GrammarLine] = []
var j = i
while j < lines.count, lines[j].isExample {
examples.append(lines[j])
j += 1
}
result.append(ContentBlock(id: result.count, kind: .exampleCard(header: nil, examples: examples)))
i = j
continue
}
// Skip headings (handled at section level)
if case .heading = line.kind {
i += 1
continue
}
result.append(ContentBlock(id: result.count, kind: .standalone(line)))
i += 1
}
return result
}
}
// MARK: - Grammar Section Grouping
private struct GrammarSection: Identifiable {
let id: Int
let heading: String?
let lines: [GrammarLine]
static func group(_ lines: [GrammarLine]) -> [GrammarSection] {
var sections: [GrammarSection] = []
var currentHeading: String? = nil
var currentLines: [GrammarLine] = []
var sectionIndex = 0
for line in lines {
if case .heading(let text) = line.kind {
// Flush the previous section
if !currentLines.isEmpty || currentHeading != nil {
sections.append(GrammarSection(id: sectionIndex, heading: currentHeading, lines: currentLines))
sectionIndex += 1
currentLines = []
}
currentHeading = text
} else {
currentLines.append(line)
}
}
// Flush final section
if !currentLines.isEmpty || currentHeading != nil {
sections.append(GrammarSection(id: sectionIndex, heading: currentHeading, lines: currentLines))
}
return sections
}
}
// MARK: - Hashable/Equatable conformance for NavigationLink
extension GrammarNote: Hashable, Equatable {
static func == (lhs: GrammarNote, rhs: GrammarNote) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
#Preview {
NavigationStack {
GrammarNotesView()
}
}