47a7871c38
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>
556 lines
20 KiB
Swift
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()
|
|
}
|
|
}
|