61 Commits

Author SHA1 Message Date
Trey T ac84b22977 Books — fix infinite render loop via value-based navigation
Opening a book chapter froze the app in an infinite render loop. Root
cause: the books screens used the eager `NavigationLink { destination }`
form inside `List`/`LazyVStack`. That form keeps the destination view
structurally parented to the source row, so `BookReaderView`'s ScrollView
got trapped inside a `List` row — a row sizes to intrinsic height, a
ScrollView has none, so the two never converge and re-measure forever.

Switch the whole books navigation chain to value-based navigation:
- practiceHomeView, BookLibraryView, BookChapterListView use
  NavigationLink(value:).
- PracticeView's NavigationStack declares the BooksRoute, Book, and
  BookChapter destinations once, at the stack root (mixing eager and
  value-based pushes in one path caused pushed screens to pop back).
- BookReaderView is built from just a BookChapter; it resolves its Book
  by slug via @Query.

Also:
- BookChapter gains a stored paragraphCount so the chapter list no longer
  decodes the full paragraph JSON on every render (bookDataVersion -> 6
  to re-seed).
- BookSpeechController builds its AVSpeechSynthesizer lazily.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:08:36 -05:00
Trey T 3ee1563cb0 Books — pre-computed per-book glossary for context-correct word lookup
The book reader's word lookup used DictionaryService, a verb-conjugation
index plus ~200 hand-typed words: ordinary nouns like "taza" returned
nothing, and homographs always lost (tapping "como" in "como siempre"
gave the verb "comer" because the verb index is checked first).

Add a glossary phase to the books pipeline (build_glossary.py): every
distinct Spanish word is translated once, in its sentence context, by
the same Claude-Code-subagent LLM step the pipeline already uses for
chapter translation. English front matter is excluded by an ES==EN
paragraph-ratio heuristic. The glossary is bundled into book_<slug>.json
and is now part of the pipeline for every book.

In the app, Book carries the decoded glossary and BookReaderView resolves
each tap automatically through cache -> glossary -> DictionaryService ->
on-device LLM, citing which source answered so a curated glossary hit
reads differently from a best-effort AI guess.

book_olly-vol2.json regenerated with a 3,658-word glossary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:44:32 -05:00
Trey T d0582c4ce7 Full Table — vary tense and verb family between consecutive prompts
randomFullTablePrompt now takes the previous prompt's tense and verb
ending and picks the next one to avoid an immediate repeat: when more
than one tense is selected the just-shown tense is excluded, and when
both -ar and -er/-ir verbs are available the next verb switches family.

Both constraints are best-effort — if honouring one would leave no
eligible fully-regular combo it is dropped, and the exhaustive
"anything eligible at all" guarantee is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:41:18 -05:00
Trey T 26ce662c60 Fix widget wiping the Books tables on every refresh
The widget extension opened the shared local SwiftData store with a
7-entity schema while the app's store has 10. SwiftData treats the
smaller schema as a migration and destructively drops the unlisted
tables — so every widget refresh deleted the bundled Book/BookChapter
rows (and DownloadedVideo), which is why books vanished after reinstalls.

Introduce SharedStore.localSchemaModels as the single source of truth
for the local schema and build the app and both widget containers from
it, so app and widget can no longer drift apart. The same class of bug
hit TextbookChapter previously; a shared list prevents a third recurrence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:12:51 -05:00
Trey T dd08a09860 Vocab Practice — apply code-review fixes for the SRS session work
Addresses the lead-review findings on the new vocab SRS/persistence code:

- Resume a persisted study group only when every stored verb resolves,
  else rebuild fresh — a partial resume desynced learnedCount from a
  shrunken queue and then persisted the loss.
- studyAgain() clears the finished group before building a new one.
- VocabStudyGroupStore.persist() collapses duplicate group records
  (cross-device sync races) down to the newest.
- Learn mode now derives its verb list from the live quiz queue, so it
  browses exactly what's left to learn instead of a stale frozen pool.
- VerbExampleGenerator retries when the first batch throws, not only
  when it returns bad data.
- Per-verb example generation tracks in-flight verbs in a Set, so the
  loading spinner clears reliably and duplicate generations are skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:59:33 -05:00
Trey T c794c013f0 Vocab Flashcards — persist the study group across launches and devices
The session queue was in-memory only: leaving the flashcard screen or
killing the app rebuilt it from scratch. The set of verbs you were
working through wasn't sticky.

VocabStudyGroup (new cloud-synced @Model) — the active standard study
set: the in-session queue (un-graduated verbs + each card's
learning-step state) as a JSON blob, plus the learned count. One active
group at a time, keyed "active-standard". CloudKit-synced, so the same
group follows you from iPad to iPhone.

VocabStudyGroupStore — fetch-or-create / persist / clear. activeGroup()
returns the newest if duplicates exist (two devices racing before sync
settles); clear() removes all duplicates.

VocabSessionQueue — CardState is now String-backed; added
init(entries:learnedCount:) to resume a saved queue and snapshot() to
export it.

VocabFlashcardPracticeView (standard kind):
  - On load, resume the active group if one exists; otherwise build a
    fresh set and persist it immediately.
  - Every answer writes the updated queue to the group.
  - Finishing the set clears the group; the completion button is now
    "Next Set" and builds a fresh group.
Review Learned (reviewLearned kind) is unaffected — it's an ad-hoc cram
and intentionally isn't persisted; its button stays "Study Again".

Registered VocabStudyGroup in the cloud container schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:42:17 -05:00
Trey T eec0fb56d5 Vocab Practice — add "Review Learned" consolidation mode
After a few Vocab Flashcards sessions, the verbs you've learned have
future SM-2 due dates, so they're excluded from new sessions (which
only pull due + new). There was no way to deliberately re-review a set
you'd already memorised.

Review Learned (new row in Practice → Vocabulary) fixes that — a cram
pass over every verb that has a VerbReviewCard, most-recently-studied
first, uncapped.

  VocabVerbPool.reviewLearnedVerbs — studied verbs sorted by
  lastReviewDate desc. Ignores due dates and the Level filter; it's a
  deliberate "review everything I've learned" pass.

  VocabFlashcardPracticeView gains a `kind` parameter
  (.standard / .reviewLearned). reviewLearned uses the new pool and —
  crucially — does NOT call VerbReviewStore.rate on graduation. Ratings
  drive the in-session learning queue for the session feel, but the
  long-term schedule is left untouched (Anki's reschedule-off cram
  behaviour), so a consolidation pass can't shove your real due dates
  weeks out.

  Header shows "Practice pass — your review schedule won't change" in
  this mode. Quiz/Learn toggle and Study Again work the same.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:24:47 -05:00
Trey T 209602eaad Docs — refresh feature reference + app_features for this session's work
Both the in-app Feature Reference and app_features.md were last updated
2026-04-21, before Books, the verb-table vocab SRS, Extra Study,
read-along, the guide enrichment, and the Practice-tab restructure.

FeatureReferenceView (Settings → How Features Work):
  - Regrouped to match the live Practice tab: Conjugation / Vocabulary
    / Reading sections.
  - Added Vocab Flashcards (Quiz + Learn modes, in-session learning
    queue), Vocab Multiple Choice, Books + read-aloud, Extra Study,
    and Guide cross-links.
  - Tense/grammar counts corrected (36 grammar notes, enriched guides).
  - "Settings That Affect Practice" now lists Cards per session and
    notes the Verbs-tab ↔ Level sync.

app_features.md (Conjuga section of the comparison doc):
  - Practice modes split into Conjugation / Vocabulary / Reading.
  - Documented the two-layer vocab SRS, Books + books pipeline,
    Extra Study, guide enrichment + cross-links, configurable session
    size, and the VerbReviewCard cloud model.
  - Data table updated (36 grammar notes, bundled books row).

Docs only — no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:57:25 -05:00
Trey T cee962c0e0 Vocab Flashcards — make session size configurable in Settings
VocabVerbPool.sessionCardLimit was hardcoded at 20. Settings now has a
"Vocab Flashcards → Cards per session" picker (10 / 15 / 20 / 25 / 30 /
50 / All) backed by the vocabSessionCardLimit @AppStorage key.

VocabVerbPool.sessionCardLimit became a computed property reading that
key (0/unset → default 20; 999 → "All"). Applies to both Quiz and Learn
modes since they share the same session pool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:06:07 -05:00
Trey T f14008f96f Vocab Flashcards — add no-pressure Learn mode alongside the SRS quiz
A Quiz/Learn toggle now sits in the flashcard toolbar (Menu, persisted
via @AppStorage):

  Quiz — unchanged. The VocabSessionQueue SRS path: tap to reveal, rate
  Again/Hard/Good/Easy, requeue, graduation feeds VerbReviewStore.

  Learn — browsing, not testing. Both sides show at once (English +
  Spanish + example sentence + speaker button), Next/Previous step
  through the session pool, looping (wraps at both ends). No rating
  buttons, no SRS writes, no requeue — just repeated exposure.

Both modes draw from the same fetched session pool (due-first + new,
capped 20) and the same lazily-generated example cache. Switching
modes is instant — the quiz queue keeps its state, the learn cursor
keeps its position; they're independent. The header shows the SRS
progress in Quiz and a plain "3 of 20" position in Learn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:53:22 -05:00
Trey T 5c0fc8ee2d Vocab Practice — proper SRS session queue with in-session learning steps
Vocab Flashcards / Multiple Choice were a flat linear walk through a
shuffled list — rating a card "Hard" or "Again" did nothing to bring it
back. Replaced with a real two-layer SRS, matching how Anki separates
in-session learning steps from the long-term schedule.

VocabSessionQueue (new) — the in-session layer. Position-based learning
steps:
  - Again → card reappears 5–8 cards later (state: learning)
  - Hard  → reappears 7–10 cards later (state: learning)
  - Good  → first time: → review state, reappears 16–24 cards later
            already in review: graduates, leaves the session
  - Easy  → graduates immediately
A card you keep failing keeps cycling until you mark it Good twice or
Easy once. answer() returns a ReviewQuality only on graduation — that's
the single rating handed to the long-term VerbReviewStore, so
intermediate Again/Hard presses no longer thrash the cross-session
SM-2 schedule.

VocabVerbPool.sessionVerbs (rewritten) — due-first ordering + a 20-card
session cap. Overdue verbs (per VerbReviewCard.dueDate) come first,
most-overdue leading; then never-reviewed verbs by frequency rank.
Not-yet-due verbs are intentionally skipped — that's the SRS schedule
doing its job. A single sitting is now bounded instead of a 100+ card
slog.

Study Again — the completion screen gets a "Study Again" button that
rebuilds the queue from the same verb set (re-shuffled), so you can run
the whole set again after finishing.

Progress display switched from "1 of 110" to "N learned · M to go",
which reflects the live queue as cards requeue and graduate.

Both vocab views now share VocabSessionQueue + VocabVerbPool; the queue
struct is pure value-type logic, easy to reason about and test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:44:43 -05:00
Trey T d61f9e50b1 Vocab Practice — speaker button to hear the Spanish word
Both vocab session reveal panes now show a speaker button next to the
Spanish infinitive. Tapping it speaks the word via the existing
SpeechService (Spanish voice), same TTS the Course flashcards use.

Flashcard mode: button beside the infinitive in the reveal pane.
Multiple choice: button beside the infinitive in the answer feedback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:15:12 -05:00
Trey T 900a927f95 Fixes #34 — drop AI-generated images from verb vocab practice
Image Playground's .illustration style can't depict abstract verbs —
"dar" (to give) generated a Shiba Inu in a meadow, "saber" a cake.
Verbs are actions, not objects; a static illustration rarely helps and
the irrelevant output was pure noise.

Removed:
  - VerbIllustration view (was in VocabFlashcardPracticeView).
  - VocabImageService.swift entirely — VerbIllustration was its only
    consumer.
  - vocabImageService from the app environment in ConjugaApp.
  - The illustration row from both vocab session reveal panes.

Vocab card reveal is now: Spanish infinitive + example sentence +
rating buttons. The example sentence already carries the learning
context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:38:01 -05:00
Trey T f0eb75a28a Fixes #33 — verb examples must actually use the verb
The example sentences in VerbDetailView (and the new vocab practice
modes) frequently used the wrong verb — a "tener" set would show
"Él estaba leyendo un libro" (estar), "Nosotros vamos a viajar" (ir),
"Tú debes estudiar" (deber). The model drifted off the target verb
partway through generating the 6-example batch, and nothing checked
the output.

Two defenses:

  Prompt grounding — VerbExampleGenerator.generate now takes a
  formsByTense map (tenseId → conjugated forms, from the new
  ReferenceStore.conjugatedForms). Each tense line in the prompt
  lists the verb's exact conjugated forms and instructs the model to
  use one of them. The model echoes a real form instead of recalling
  (and mis-recalling) the conjugation.

  Output validation — every generated sentence is checked against the
  conjugation table via accent/case-folded whole-word matching. Any
  sentence that doesn't contain a real conjugated form of the verb is
  rejected. Failures trigger one regeneration pass; anything still
  wrong is dropped rather than displayed. Better to show 4 correct
  examples than 6 with 2 wrong.

  Cache invalidation — VerbExampleCache now persists a versioned
  wrapper (version 2). Pre-fix cached example sets — which may contain
  wrong-verb sentences — fail the version check and are discarded, so
  they regenerate cleanly under the new path.

Callers updated: VerbDetailView, VocabFlashcardPracticeView,
VocabMultipleChoicePracticeView all build formsByTense from
ReferenceStore and pass it through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:57:57 -05:00
Trey T f4c139aed0 Fixes #35 — vocab practice no longer doubles the verb's "to"
VocabFlashcardPracticeView and VocabMultipleChoicePracticeView rendered
the English prompt as "to \(verb.english)". Every verb in conjuga_data
already stores english with a leading "to " (all 1750 of them), so the
prompt came out as "to to be", "to to run", etc.

Dropped the prefix — verb.english is shown verbatim. Same fix applied
to the Image Playground concept string so generated illustrations are
prompted with "to be" rather than "to to be".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:43:34 -05:00
Trey T 0af8e648fe Vocab Practice crash — defensive Dictionary init + correct tense count
Crash: Swift/NativeDictionary.swift:792 Fatal error: Duplicate values
for key: 'imp_tú', triggered the first time a user rated a vocab card
and the in-flight example generation tried to materialise.

Root cause: VerbExampleGenerator.generate() builds a [tenseId: example]
dictionary from the model's output via Dictionary(uniqueKeysWithValues:),
which traps on duplicates. The generator's @Generable schema declares
@Guide(.count(6)) on the examples array, so the LLM is forced to return
exactly 6. The new Vocab Flashcards / Multiple Choice views called
generate(... tenseIds: ["ind_presente"]) — only one tense — which left
the model to invent the other 5 tenseIds; it duplicated 'imp_tú' and
the dictionary init trapped.

Three fixes:

  Services/VerbExampleGenerator.swift — use Dictionary(_:uniquingKeysWith:)
  with first-wins so the generator can never crash regardless of caller
  shape.

  Views/Practice/Vocab/VocabFlashcardPracticeView.swift and
  Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift — pass
  the canonical 6-tense set (VocabExampleTenseIds.canonical, same as
  VerbDetailView uses), then pick the ind_presente example for the
  card. Caches all six in VerbExampleCache as a side effect.

  Views/Verbs/VerbListView.swift — replace empty-string systemImage
  on the Level menu Labels with "circle" so the device console isn't
  spammed with "No symbol named '' found in system symbol set" every
  time the user opens the filter menu.

The crash analysis I gave earlier (CloudKit schema migration of
VerbReviewCard) was wrong — device console shows the real culprit. No
CloudKit-side changes needed; the new model stays in the cloud
container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:02:34 -05:00
Trey T c890095610 Vocab Practice — verb pool, Settings level filter, per-verb SRS
Vocab Practice used a deck picker over VocabCard rows. That meant it
ignored the Settings level toggles entirely and operated on a totally
separate vocabulary universe than the conjugation modes. Rewired
end-to-end:

Pool source
  Replaced VocabCard with the Verb table. The pool is now
  ReferenceStore.fetchVerbs(selectedLevels: UserProgress.selectedVerbLevels)
  — the same call PracticeSessionService uses for conjugation. Changes
  to level toggles in Settings (or the Verbs tab, which also writes to
  this field) immediately affect Vocab Practice.

Entry flow
  Deleted VocabPracticeEntryView. Practice → Vocabulary now has two
  direct entries:
    • Vocab Flashcards — verb.english → tap → verb.infinitive
    • Vocab Multiple Choice — verb.english → pick from 4 infinitives
  Both pull from the same level-filtered pool, shuffled.

Per-verb SRS
  New VerbReviewCard @Model (cloud-synced, mirrors CourseReviewCard's
  SM-2 fields but keyed by verbId). VerbReviewStore.rate(verbId:quality:)
  applies the existing SRSEngine. Registered in cloud container schema
  in ConjugaApp.swift.

Example sentences
  Lazy-generated via VerbExampleGenerator on first reveal, cached
  through VerbExampleCache (same path VerbDetailView uses). Empty until
  the example arrives — block hides itself if Apple Intelligence isn't
  available.

AI illustration
  VerbIllustration replaces VocabIllustration; same Image Playground
  pipeline. Cache key uses ("verb", infinitive, english) so verbs and
  course-deck vocab never collide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:31:12 -05:00
Trey T 164a0a1bb7 Verbs — share level filter with Practice via UserProgress
VerbListView used a local @State selectedLevel that reset whenever
the user left the tab and was disconnected from Practice's verb
sourcing. PracticeSessionService reads UserProgress.selectedVerbLevels;
the Verbs tab now does the same.

  Level menu becomes multi-select (toggle each level in/out, mirrors
  SettingsView's pattern). "All Levels" enables every level; tapping
  again on a level removes it from the active set. Picking "All"
  while everything is already on is a no-op.

  Filter logic loops over the active level set and admits a verb if
  any active level matches via VerbLevelGroup.matches.

  Active-filter chip shows "Basic", "Elementary", etc. when one level
  is on, or "N levels" when multiple are on. Tapping the chip resets
  to all-levels.

  Empty set is treated as "no filter applied" on the Verbs list, but
  setAllLevels(enabled: false) falls back to [.basic] rather than an
  empty set — Practice treats empty as "no verbs", so we guard against
  leaving the user with an empty pool.

Practice already reads UserProgress.selectedVerbLevels, so changes
made in the Verbs tab now flow into Practice automatically. No
Practice-side code changes needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:18:23 -05:00
Trey T d49eb38a6d Books — bump bookDataVersion to 4 to force re-seed
A device install was showing the Books row but no books inside. The
seed code is intact (bundle resource present, schema registered,
explicit-slug fallback in place). Bumping the version flips
versionCurrent to false on every existing install, forcing
refreshBooksDataIfNeeded to wipe + re-seed on next launch. The
diagnostic prints added earlier ([DataLoader] bundledBookJSONURLs
found N files / Book seeding complete) will tell us if it still
fails this time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:09:00 -05:00
Trey T 0b7d4a73ad Add Vocab Practice — English-first flashcards + multiple choice, with AI illustrations
Practice tab restructured into three sections:
- Conjugation: the 6 conjugation modes + the Common Tenses / Weak
  Verbs / Irregularity Drills focus buttons.
- Vocabulary: new "Vocab Practice" entry (flashcard or multiple
  choice, deck-picker on entry) + the existing Vocab Review.
- Reading: Stories, Books, Lyrics, Conversation, Listening, Cloze
  (moved here from the flat list).

VocabPracticeEntryView lets the user pick any course/textbook deck
(or "All decks") and a mode (Flashcard / Multiple Choice). Last-used
choice is remembered via @AppStorage.

VocabFlashcardPracticeView:
  Front shows the English meaning. Tap to reveal the Spanish word,
  example sentences from the card, an AI-generated illustration of
  the concept, and Again/Hard/Good/Easy rating buttons. SRS updates
  via the existing CourseReviewStore.rate() path.

VocabMultipleChoicePracticeView:
  English prompt, 4 Spanish options. Distractors come from the same
  deck and prefer matching part-of-speech (via
  DictionaryService.lookup) — falls back to random when POS is
  unknown or the deck has fewer than 4 cards. After answer: reveal
  correct/incorrect, the Spanish word, examples, illustration, and
  the same rating buttons.

VocabImageService wraps Apple Intelligence's ImageCreator
(iOS 18.2+) for on-device illustration generation. Caches PNG
results to disk under Caches/VocabImages keyed by
SHA256(deck+ES+EN). In-flight dedup keeps concurrent requests for
the same key sharing one task. Falls back to a placeholder UI when
Apple Intelligence isn't available (older devices / disabled in
Settings) — detected lazily on the first failed ImageCreator init.

EN-first direction is enforced regardless of the underlying deck's
isReversed flag, so the user sees the English-to-Spanish recall
direction they asked for even when practising a reversed course
deck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:02:02 -05:00
Trey T 9aa4d0836d Guide — bidirectional cross-link chips between tense guides & grammar notes
After the full enrichment pass, both the Tenses and Grammar surfaces of
the Guide tab cover overlapping material — WEIRDO appears in both
"Subjuntivo Presente" and "Subjunctive Triggers", preterite↔imperfect
contrast in three places, etc. Instead of trimming either body and
losing content, add a small chip row at the top of each detail view
linking directly across.

GuideCrossLinks.swift (new) — curated tense→[noteId] map covering 18 of
the 20 tenses. The two without aligned notes (ind_pluscuamperfecto,
ind_preterito_anterior) don't show chips. The reverse map (noteId→
[tenseId]) is derived once at static init and sorted by canonical tense
order so chips appear in conjugation-table order.

GuideDetailView — "Related grammar" indigo chip row directly under the
header. Tap a chip → switch to the Grammar segment with that note
selected.

GrammarNoteDetailView — "Used in tenses" orange chip row directly under
the title. Tap a chip → switch to the Tenses segment with that tense
selected.

The GuideView segment-change handler now only clears the *other* tab's
selection so programmatic jumps keep their destination intact; manual
segment swipes still feel "fresh" like before.

No content is removed. Users get a deeper-dive path one tap away in
either direction, and the redundancy becomes a feature instead of a
maintenance hazard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:59:46 -05:00
Trey T 5db4b014a9 Guide — full enrichment pass: 19 tense guides + 36 grammar notes
Brings every tense guide and grammar note in the app up to teacher-
handout depth via parallel research subagents drafting against a
shared "thorough" checklist (TL;DR, usages, conjugation table, common
irregulars, mnemonic, top pitfalls, contrast with neighbour topic,
real-world dialogue example).

Tense guides — Conjuga/conjuga_data.json (tenseGuides[].body)
  All 19 remaining guides rewritten. The 20th (subj_presente) was
  enriched in the prior commit. Each body now ~4-5.5K chars (vs the
  500-1500 chars of the pre-pass reference cards), covering:
  - All five indicative tenses, both conditionals, both imperatives.
  - Full subjunctive set including the archaic futuro / futuro
    perfecto, framed honestly with "recognise, don't produce" guidance.
  - Per-tense conjugation patterns and the top 5-15 irregular verbs.
  - Tense-vs-tense contrasts (preterite↔imperfect, future↔ir-a,
    -ra↔-se past subjunctive, etc.).
  - Pitfalls that English speakers actually make.

Grammar notes — Conjuga/Conjuga/Models/GrammarNote.swift
  All 36 notes audited and rewritten where the existing body was
  missing one of: explicit mnemonic, contrast pair, pitfalls section,
  or coverage of a key sub-topic. None copied verbatim — every note
  got at least one of those slotted in. Notable additions:
  - DOCTOR/PLACE, WEIRDO, ESCAPA, RID, PRODDS, BANGS, RRPIA mnemonics
    where missing.
  - commands-imperative: nosotros + vosotros forms were entirely
    absent; both added with the -d/-os and present-subjunctive rules.
  - relative-pronouns: el que/el cual distinction, cuyo, lo que/lo
    cual, donde/adonde.
  - se-constructions: all 6 uses including the le→se substitution.
  - irregular-yo-verbs: impact on subjunctive and negative tú command.
  - Plus 5-item pitfalls sections on every note that lacked one.

Tooling — Conjuga/Scripts/guide-enrichment/
  - PLAN.md (prior commit) — the audit, checklist, and priority order
    that drove this pass.
  - apply_drafts.py (new) — reads drafts/out/*.md, swaps tense guides
    into the JSON and grammar notes into the Swift source via regex on
    the GrammarNote(...) declarations. Handles multi-block `#` comment
    headers some agents emitted. drafts/in/ and drafts/out/ are
    gitignored — regeneratable from current state.

DataLoader.swift — courseDataVersion 8 → 9 so existing installs re-
seed all guides on next launch.

Verification:
  - `swift -frontend -parse` on GrammarNote.swift succeeds (exit 0).
  - JSON validates (python3 json.load round-trip).
  - Triple-quote count is even (72 = 36 pairs, matching 36 notes).
  - Full xcodebuild verify deferred — local SDK install was disrupted
    by an Xcode update; will retest as part of the next ad-hoc deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:50:35 -05:00
Trey T de446b2301 Guide — enrich present-subjunctive entry with WEIRDO + ESCAPA + plan
The present-subjunctive guide was surface-level: two numbered usages
and a handful of examples, no mnemonic and no structural trigger cue.
That's the recurring problem with the tense guides — they're reference
cards, not teaching materials.

This commit fixes the immediate gap and lays out a plan to fix the
rest:

  Conjuga/conjuga_data.json — subj_presente body expanded from 794 to
  3670 chars. Adds the WEIRDO mnemonic with per-letter triggers and
  examples (Wishes, Emotions, Impersonal, Recommendations, Doubt,
  Ojalá), the ESCAPA adverbial-conjunction set, the "que + change of
  subject" structural rule, adjectival clauses with unknown
  antecedents, and the future-time-clause rule (cuando / hasta que /
  en cuanto).

  Scripts/guide-enrichment/PLAN.md (new) — audit of all 20 tense
  guides and 36 grammar notes, tier-1/2/3 prioritisation, "thorough"
  checklist (TL;DR, usages, conjugation, irregulars, mnemonic,
  pitfalls, contrast, dialogue example), research sources, per-topic
  workflow, effort estimate.

  DataLoader.swift — courseDataVersion 7 → 8 so existing installs
  re-seed the new body on next launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:33:09 -05:00
Trey T a416233a2d Books — read-aloud mode with active-word highlight and tap-to-define
Adds a TTS read-along to the book reader. Tap the play button in the
toolbar; AVSpeechSynthesizer reads the chapter paragraph-by-paragraph
with the current word highlighted in yellow, auto-scrolling the active
paragraph to centre. Tap any word during read-along to pause and open
the definition sheet; reading resumes when the sheet dismisses.

Behavior per spec:
- Tap-to-define interrupts the synth (pauseSpeaking at: .immediate) and
  resumes on sheet dismiss.
- Voice picker sheet (waveform.circle toolbar button) lists installed
  Spanish voices grouped by Premium / Enhanced / Default quality, with
  a "Download more voices…" row that opens iOS Settings (no public
  deep-link to Accessibility → Spoken Content exists; the footer spells
  out the path).
- Speed picker (Slow / Normal / Fast) drives AVSpeechUtterance.rate.
- Stops at chapter end, no auto-advance to the next chapter.
- Vocabulary lines shaped `palabra = meaning` are skipped — the synth
  would otherwise say "palabra equals meaning" and they're reference
  material, not prose.

Audio session uses .playback + .spokenAudio mode and is properly
deactivated with .notifyOthersOnDeactivation on stop() so music apps
resume cleanly after reading ends.

Voice/rate persisted via @AppStorage; controller picks them up
onAppear and writes back through Bindings the picker mutates.

Word-index space in BookSpeechController.wordRanges(in:) matches
BookReaderView's split(separator: " ") rendering exactly — both split
on ASCII U+0020 only, so willSpeakRange callbacks resolve to the right
visible word.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:42:09 -05:00
Trey T 70d8299df8 Books — robust bundle lookup so ad-hoc device installs always seed
bundledBookJSONURLs() was relying solely on
Bundle.urls(forResourcesWithExtension:subdirectory:), the directory-
enumeration API. That API is observed to return empty in some on-device
configurations even when the resource is present, which would cause
seedBooks() to silently no-op and leave BookLibraryView showing "No
Books" despite the JSON being bundled correctly.

Switched to the same pattern textbook seeding uses: explicit per-slug
Bundle.url(forResource:withExtension:) with a bundleURL.appending-
PathComponent fallback. Directory enumeration is kept as a secondary so
future books bundled without code changes still get picked up.

Also added diagnostic prints to refreshBooksDataIfNeeded and the URL
discovery step so device logs reveal what happened if seeding still
falls through.

Bumped bookDataVersion to 3 so existing installs re-trigger
refreshBooksDataIfNeeded → seedBooks on next launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:36:16 -05:00
Trey T 51067e23fd Fix Books navigation — tapping a book no longer re-pushes the library
BookLibraryView is itself pushed from PracticeView's NavigationStack,
so the .navigationDestination(for: Book.self) it declared was a
non-root registration. Combined with NavigationLink(value: book), that
resolved the push to *both* the destination handler and the closure
that produced BookLibraryView originally — pushing the chapter list
underneath, then re-pushing the library on top. Hitting back popped
the library and revealed the chapter list, in the wrong order.

Switched both Library→ChapterList and ChapterList→Reader to closure-
based NavigationLinks. Destinations attach directly to the link, no
type-keyed registry involved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:18:04 -05:00
Trey T 05a367fdbe Books — capture <li> vocab bullets the extractor was silently dropping
extract_epub.py was walking <p> only, but every "Vocabulario" section in
the Olly Richards EPUB lives inside <ul><li>...</li></ul>. That meant
the heading made it through but the entries didn't — 680 vocab lines
across 24 sections in this book were missing from the bundled JSON.

Audit (text-node owner by closest block ancestor) confirmed <li> is the
only silent drop: 5,260 nodes in <p>, 1,960 in <li>, 0 anywhere else.
No <h1>-<h6>, tables, or blockquotes in this EPUB at all.

Fix: walk find_all(["p", "li"]) in document order so bullet entries
slot in right after their "Vocabulario" / list heading. Re-extracted
(2,646 → 3,326 paragraphs), re-translated all 118 jobs in parallel
Claude Code subagents. translate_chapters.py prompt template now tells
subagents to keep bilingual `palabra = meaning` lines verbatim — both
sides already coexist on the line.

Bumped bookDataVersion to 2 so refreshBooksDataIfNeeded re-seeds.
Verified in simulator: all 13 chapter row sizes grew (e.g. ch6
18,295→20,951 chars).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:10:34 -05:00
Trey T 09e49bda2c Add Books — read EPUB-imported books in Practice with tap-to-define
New "Books" row in the Practice tab opens a library of bundled bilingual
books. Each chapter renders Spanish paragraph-by-paragraph; tap any
word for a definition sheet (DictionaryService with on-device AI
fallback), or toggle the toolbar button to swap to the pre-computed
English translation inline.

Local-only Book + BookChapter SwiftData models added to the local
container schema (reset version bumped to 5). DataLoader.seedBooks
walks the bundle for `book_*.json` resources, so future books drop in
without touching app code — just bundle a new JSON and bump
bookDataVersion.

First book: Olly Richards' "Spanish Short Stories For Beginners
Vol 2" — 13 chapters, 2,646 paragraphs, bilingual.

Scripts/books/ is the repeatable pipeline for future EPUBs:
extract_epub.py → translate_chapters.py (per-chapter resumable jobs) →
bundle_book.py. Translation is done by parallel Claude Code subagents
reading per-job input files and writing output files — no API key
required, matching the pattern used for the textbook vocab vision
pass. See Scripts/books/README.md for the full how-to.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:21:44 -05:00
Trey T ade091f108 Add Extra Study — star cards during review, study just the marked ones
Per-week "Extra Study (N)" row appears at the end of each LanGo week
section when at least one card is marked. Cards are marked from inside
VocabFlashcardView via a star next to the speaker on reveal. Marks are
keyed by the same SHA256 hash CourseReviewCard uses, so a mark and its
SRS state describe the same logical card.

ExtraStudyMark is CloudKit-synced (private DB), with uniqueness enforced
by fetch-or-create on id since CloudKit forbids @Attribute(.unique).
Skipped for textbook courses: DeckStudyView nils out the mark context
when the deck's courseName matches a TextbookChapter, and CourseView
hides the row when the active course is a textbook — so there are no
orphan marks the user can't reach.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:38:39 -05:00
Trey T 05a0cc0d17 Issue #32 cleanup — drop the last 5 mis-oriented vocab pairs
Two small fixes after the LLM-vision pass:

1. merge_pdf_into_book.py — when the LLM classifies an image as 'hybrid'
   but extracts zero pairs (e.g., a conjugation table whose only English
   text is on the section header that was excluded by the prompt rules),
   respect that decision instead of falling through to the bbox/heuristic
   pipeline. Previously: 1 chapter-2 estar conjugation table generated
   4 bad pairs from the heuristic fallback.

2. fix_vocab.py language_score — recognize Spanish present-perfect
   ('he tenido', 'He andado por este pueblo') as Spanish. The classifier
   was treating the auxiliary 'he'/'has'/'ha' as English subject pronouns,
   producing false-positive mis-orientation flags on 4 chapter-15/20/23
   present-perfect example tables.

Result: mis-oriented vocab pairs across the book go from 5 → 0.
textbookDataVersion bumped to 14.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:52:53 -05:00
Trey T f368c24ad6 Fixes #32 — LLM vision pass for vocab pairs, fixes scrambled English/Spanish
The bbox-OCR pipeline mis-paired ~114 vocab tables across the book — the
chapter 7 "Other Idioms" image (issue #32) being the most visible.
Three failure modes were collapsing the data:
  1) classifier blind to subject pronouns ("yo", "I", etc.)
  2) right-then-left OCR reads on 2-col tables
  3) Y-cluster drift on multi-line cells in 4-col layouts

Replaced the entire vocab-extraction tier with a Claude vision pass over
all 931 vocab images. Output is keyed by image with three classifications:
  - pair_table       (extract all Spanish↔English pairs)
  - reference_only   (Spanish-only conjugation tables — no pairs, UI shows
                      the flat OCR lines as a reference list instead)
  - hybrid           (some header pairs + reference content beneath; only
                      the genuine pairs become cards)

merge_pdf_into_book.py now picks pair source by priority:
  llm-vision → bounding-box OCR → block-alternation heuristic.

Numbers (across the whole book):
  - mis-oriented tables: 114 → 5
  - quarantined cards:   250 → 2
  - extracted pairs:     2832 → 4569

textbookDataVersion bumped to 13. Per-batch agent outputs gitignored
under Conjuga/Scripts/textbook/paired_vocab_llm/ — only the merged
paired_vocab_llm.json (also gitignored) is needed to rebuild.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:48:04 -05:00
Trey T 90aea92fba Fix Full Table eligibility — accept ordinary verbs, reject orto
The eligibility filter required every form's regularity tag to equal
"regular", but the data uses four labels:
  - regular   (179 forms — curated paradigm verbs)
  - ordinary  (50,992 forms — pattern-following verbs like hablar, comer)
  - irregular (8,653)
  - orto      (176 — orthographic spelling changes like busqué)

Result was a 27-combo eligible pool, ~26 of which were -ir verbs in
present tense — every Full Table prompt landed on the same handful of
verbs.

Pulled the rule into a SharedModels function (FullTableEligibility) so
it's testable in isolation. Accepts "regular" + "ordinary" (both mean
"follows the pattern"); rejects "irregular" and "orto". 9 unit tests
cover the matrix including edge cases (incomplete forms, mixed labels,
unknown values).

PracticeSessionService.makePromptIfFullyRegular now delegates to
FullTableEligibility, sorting forms by personIndex so the regularity
array lines up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:26:34 -05:00
Trey t dce2cc1f51 Make Full Table level-agnostic, fix the streak system end-to-end
Full Table (issue from chat): drop the level filter — Full Table tests
regular conjugation patterns, not vocabulary recognition, so restricting
to Basic-level verbs collapsed the eligible pool to two combos
(vivir present, ir future). Pool now draws from all 1,750 verbs. Random
sampling first; if 40 attempts fail we fall through to a deterministic
shuffled scan that guarantees finding any eligible (verb, tense) combo
when one exists. Returning nil now happens only when the user's filters
genuinely produce zero eligible prompts. The view replaces its silent
blank screen with a ContentUnavailableView pointing at the settings
that need adjusting. FeatureReferenceView documents the level exception.

Streak (issue #31 follow-up): activity recording was scoped to flashcard
and Full Table reviews only, so spending an hour on textbook work,
guides, videos, or AI chat could break a "streak" that the dashboard
kept displaying as if it were intact. Three fixes:

  1. Extract ReviewStore.recordActivity(context:) — a streak-only entry
     point that any user-initiated learning action can call.
  2. Add UserProgress.validateStreakIfStale(today:context:) — resets a
     broken currentStreak to 0 immediately, called from app launch and
     dashboard appear so the displayed number is never a lie.
  3. DailyLog formatter pins POSIX locale + current timezone so the
     yyyy-MM-dd strings can't drift across locales.

Wired recordActivity into every previously-silent learning action: chat
send, story-quiz completion, textbook exercise submit, grammar exercise
completion, course-deck study finish, week test / checkpoint save,
listening + pronunciation check, cloze quiz completion, lyrics word
lookup, video stream / play / download success, sentence-builder check,
and course-vocab SRS rate (which was bypassing ReviewStore entirely).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:24:27 -05:00
Trey T 06b47d37cf Fix video action button wrapping + bundle textbook seed JSONs
- VideoActionsView: stack icon over title via a custom label style so
  the Stream/Download/Play row fits iPhone width without wrapping
  mid-word ("Stre am", "Down load").
- project.yml: add textbook_data.json and textbook_vocab.json to the
  Conjuga target's Copy Bundle Resources. They were missing, so
  DataLoader.seedTextbookData early-returned every launch and the
  textbook never appeared in the Book tab. Regenerated Conjuga.xcodeproj.
2026-04-24 17:32:36 -05:00
Trey t f993bfbb96 Refresh README with recent features
Add YouTube video integration, reflexive-verb list + practice-pool
filters, verb-detail example sentences, lyrics word lookup. Bump iOS /
Xcode requirements to 26. Correct grammar-note count and document the
YouTubeKit + AVFoundation muxing architecture plus the video-curation
script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 07:33:41 -05:00
Trey t fcb907718a Re-curate videos toward preferred channels
Swap 24 tense-guide / grammar-note videos to The Language Tutor's
numbered lesson series where a matching lesson exists, filling the two
remaining gaps (ind_preterito_anterior → Lesson 65, estar-gerund-
progressive → Lesson 113). All 32 TLT picks preserved on this pass.

For the non-TLT slots, prefer BaseLang's beginner lesson series where a
topic-specific video exists: ser-vs-estar, preterite-vs-imperfect,
subjunctive-triggers, object-pronouns, conditional-if-clauses,
tener-expressions, future-vs-ir-a, possessive-adjectives,
irregular-yo-verbs, and stem-changing-verbs.

Retire both Tell Me In Spanish videos (personal-a → castellano4U,
types-of-irregular-verbs → Master IRREGULAR VERBS Complete Lesson).

Generator header note clarifies that "not available on this app" rows
are a transient yt-dlp extraction limit — videos still play when tapped
in the app via the Stream button, which opens youtube.com externally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 07:24:53 -05:00
Trey t 9c7033d1b4 Add curated-videos markdown report + generator script
youtube_videos.md lists every entry in youtube_videos.json with its
tense-guide / grammar-note id, title, channel, upload date, duration,
views, and likes (where public). Also flags the two topics with no
curated video so the gap is auditable in one place.

generate_videos_markdown.py queries yt-dlp in parallel for each unique
videoId and writes the markdown. Rerun when curation changes. One
current entry (saber-vs-conocer → j87i7MVCvIE) is now marked Private
Video — needs re-curation as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 07:07:41 -05:00
Trey t 0a099c3fc9 Fixes #30 — Downloaded videos include audio via adaptive-stream muxing
YouTubeKit returns stream URLs only; its own README punts on the
progressive-vs-adaptive problem. Most modern YouTube videos above 360p
serve video and audio as separate DASH tracks, so the previous "any MP4"
fallback silently grabbed a video-only track and the downloaded file had
no audio.

VideoDownloadService now resolves a DownloadPlan up front. Progressive
MP4 (when one exists) still streams straight to the final destination.
Otherwise the service downloads the best MP4 video track + best M4A
audio track to temp files, then combines them into a single MP4 via
AVMutableComposition + AVAssetExportSession. Passthrough preset first
(lossless container rewrite) with a fallback to highestQuality if the
codec combination requires re-encoding. Temp files are cleaned up on
both success and failure.

DownloadStatus replaces the bare Double progress so the UI can show
per-phase labels (Video %, Audio %, Finalizing…). Muxing progress is
rendered as an indeterminate spinner since AVAssetExportSession's
progress property doesn't cross actor boundaries cleanly.

Also retires the deprecated Stream.subtype comparison in favor of
Stream.fileExtension, matching YouTubeKit's current API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 07:00:16 -05:00
Trey t 57f945a4d3 Fixes #29 — Delete downloaded video inline on the guide detail view
The middle button in the Stream/Download/Play row now cycles through the
full download lifecycle instead of ending at a disabled "Downloaded"
checkmark. Once a video is on disk the button becomes a red, destructive
"Delete" with a trash icon; tapping presents a confirmation dialog, and
confirming removes the file + SwiftData row, flipping the button back to
"Download" and disabling Play.

Settings → Downloaded Videos retains the swipe-delete and "Delete all"
affordances for bulk management.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 06:46:51 -05:00
Trey t 5777a210cd Fixes #21 — Curated YouTube videos per guide + grammar item
Each of the 56 tense guides and grammar notes gets a curated YouTube video
attached (54 with picks, 2 silent nulls on rare / hard-to-find topics).
Users can stream in YouTube/Safari, download via YouTubeKit for offline
viewing, or play the local MP4 full-screen via AVPlayer.

YouTubeVideoStore loads the bundled youtube_videos.json at launch and serves
lookups by tense id or grammar note id. VideoDownloadService resolves the
best progressive MP4 stream off the main actor (YouTubeKit isn't Sendable),
writes to documents/videos/<videoId>.mp4, and records a DownloadedVideo row
in the local SwiftData container so the app knows what's on disk across
launches.

VideoActionsButtonRow is the unified UI for both detail views: three large
buttons — Stream (red, always enabled), Download (blue, disabled while in
flight and after completion, shows progress), Play (green, enabled only
when downloaded). Full-screen cover on tap. Settings gains a Downloaded
Videos list with swipe-delete, total-size summary, and a 500 MB warning.

Local store reset version bumped to 4 for the new DownloadedVideo schema.

Known fragility: YouTubeKit scrapes YouTube's private stream API and will
break when YouTube changes their internal format. Streaming keeps working
regardless.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:51:19 -05:00
Trey t 98badc98ad Fixes #28 — Curated reflexive verb list on detail + practice filter
Bundles the 100 most common reflexive verbs from spanishwithdaniel.com as a
canonical list and wires it through the UI. Compound list entries (recibirse
/ graduarse, equivocarse / confundirse) are split. Trailing prepositions and
set-phrase completions are captured as usageHint (e.g. acordarse "de",
ponerse "de acuerdo").

ReflexiveVerbStore loads the JSON at launch and exposes lookups by base
infinitive, both via @Environment for SwiftUI and a static shared instance
for services. Verbs whose bare infinitive isn't in the list skip the UI
treatment silently.

VerbDetailView shows a new Reflexive section with the reflexive infinitive,
usage hint, and English meaning when there is a match. VerbListView gains a
"Reflexive verbs only" filter alongside the existing Level and Irregularity
filters. Settings adds the same flag so it also constrains the practice
pool; PracticeSessionService applies the reflexive filter in all six pick
paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 10:56:04 -05:00
Trey t 4093b5a7f3 Fixes #27 — AI-generated example sentences on verb detail
Tapping a verb now shows six example sentences beneath the conjugation table,
one per core tense (Present, Preterite, Imperfect, Future, Present Subjunctive,
Imperative). Each example renders the tense label, Spanish sentence, and
English translation in the DeckStudyView style.

Generation uses Foundation Models with a @Generable schema that pins each
response to the requested tenseId and forces tú/nosotros subjects for
imperatives. Results are cached as JSON in the Caches directory keyed by
verb id (DictionaryService pattern); cache misses regenerate on demand.

Devices without Apple Intelligence see an inline notice instead of the
loading state. No network dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:57:32 -05:00
Trey t 5d3accb2c0 Fixes #26 — Multi-select levels and irregular-type practice filter
UserProgress gains selectedLevelsBlob and enabledIrregularCategoriesBlob
(mirrors the existing tense-blob pattern). The multi-level setter keeps the
legacy selectedLevel String in sync with the highest-ranked selection, so
widget sync, AI scenarios, and achievement checks keep working unchanged.
Legacy single-level users are migrated on first read.

Settings replaces the level Picker with per-level toggles and adds an
Irregular Types section with three toggles. Practice pool is the literal
intersection: empty levels means zero results, empty irregular categories
means no irregularity constraint.

Pure filter logic lives in SharedModels (PracticeFilter, VerbLevel.highest)
and is covered by 20 Swift Testing cases. ReferenceStore delegates so the
intersection behavior is unit-tested without a ModelContainer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:36:25 -05:00
Trey t 3d8cbccc4e Fixes #25 — Long-press lyric words for definition and tense
Tokenize Spanish lyric lines into a flow layout of underlined, long-pressable
words. Long-press (0.35s) opens a sheet with base form, English, part of
speech, and a Tense · person row for verbs. Unknown words silently no-op.
English gloss lines remain untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:18:15 -05:00
Trey t cc6ec70ed9 Update README and app_features to reflect current feature set
README: expanded from the original terse list into categorized sections
covering the six conjugation practice modes, textbook reader, AI chat
and stories, listening/pronunciation, cloze, lyrics, vocab SRS, offline
dictionary, grammar notes/exercises, and CloudKit sync. Architecture
section now documents the dual local/cloud SwiftData stores with the
App Group ID, the widget-schema-must-match requirement, and the
Scripts/textbook extraction pipeline.

app_features.md: added a full Conjuga section (practice modes, verb
reference, grammar, dictionary, sync, widgets, data counts) alongside
the existing ConjuGato and Conjuu ES analyses; added Conjuga as a
first column in the comparison table with rows for the new capability
axes (AI, textbook, speech, offline dictionary, lyrics, CloudKit,
widgets); added a "Conjuga excels at" strengths section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:36:15 -05:00
Trey t d99d88e73c Add CLAUDE.md with project rules
Codifies the rule that Claude must not run git commit or git push
without an explicit request from the user in the current turn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:31:14 -05:00
Trey t 8e1c9b6bf1 Make textbook data self-heal after widget schema wipes
Root cause of the repeatedly-disappearing textbook: both widget timeline
providers were opening the shared local SwiftData store with a schema
that omitted TextbookChapter. On each widget refresh SwiftData
destructively migrated the store to match the widget's narrower schema,
dropping the ZTEXTBOOKCHAPTER rows (and sometimes the table itself).
The app then re-created an empty table on next open, but
refreshTextbookDataIfNeeded skipped re-seeding because the UserDefaults
version flag was already current — leaving the store empty indefinitely.

Three changes:

1. Widgets (CombinedWidget, WordOfDayWidget): added TextbookChapter to
   both schema arrays so they match the main app. Widget refreshes will
   no longer drop the entity.

2. DataLoader.refreshTextbookDataIfNeeded: trigger now considers BOTH
   the version flag and the actual on-disk row count. If rows are
   missing for any reason (past wipes, future subset-schema openers,
   corruption), the next launch re-seeds. Eliminates the class of bug
   where a version flag lies about what's really in the store.

3. StoreInspector: reports ZTEXTBOOKCHAPTER row count alongside the
   other entities so we can confirm state from logs.

Bumped textbookDataVersion to 12 so devices that were stuck in the
silent-failure state re-seed on next launch regardless of prior flag
value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:31:14 -05:00
Trey t d9ddaa4902 Fixes #23 — Add irregularity filter to the Verbs list
The toolbar Filter menu now has two sections:
- Level (existing)
- Irregularity: Any Irregular / Spelling Change / Stem Change /
  Unique Irregular

Filters combine, so "Basic" + "Unique Irregular" narrows to the
foundational ser/ir/haber-class verbs. Categories are derived at load
time from existing IrregularSpan rows using the same spanType ranges
already used by PracticeSessionService (1xx spelling, 2xx stem, 3xx
unique), so no schema or data changes are required.

UI additions:
- Per-row icons (star / arrows / I-beam) show each verb's
  irregularity categories at a glance, tinted by type.
- When any filter is active, a chip bar appears under the search
  field showing the active filters (tap to clear) and the resulting
  verb count.
- Filter toolbar icon fills when any filter is applied.

Data coverage: 614 / 1750 verbs are flagged irregular — 411 spelling,
275 stem-change, 67 unique — consistent with canonical lists from
SpanishDict, Lawless Spanish, and Wikipedia.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:29:46 -05:00
Trey t cdf1e05c4c Fixes #22 — Skip irregular verbs in Full Table practice
randomFullTablePrompt now rejects any (verb, tense) combo where any
form has regularity != "regular" and keeps retrying (40 attempts) until
a fully-regular combo is picked. Full Table practice is meant for
drilling regular conjugation patterns — irregular tables belong in the
Irregularity Drills mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:49:46 -05:00
Trey t 455df18dad Stop silent save failures from locking out textbook re-seeds
Two bugs were causing chapters to disappear on every relaunch:

1. seedTextbookData used `try? context.save()` (swallowing errors) and
   returned `inserted > 0`, so a failed save still reported success.
   Callers then bumped UserDefaults textbookDataVersion and subsequent
   launches skipped the re-seed entirely — with no rows on disk.

2. refreshTextbookDataIfNeeded wiped chapters via the batch-delete API
   `context.delete(model: TextbookChapter.self)`, which hits the store
   directly without clearing the context's .unique-id index. Re-inserting
   chapters with the same ids could then throw a unique-constraint error
   on save — also silently eaten by `try?`.

Fixes:
- seedTextbookData now uses do/catch around save(), returns false on
  error, and verifies persistence via fetchCount before returning true.
- refreshTextbookDataIfNeeded fetches and deletes chapters individually
  so the context tracks the deletion cleanly; wipe save is also now
  checked and bails early on failure.
- Bumped textbookDataVersion to 11 so devices poisoned by the previous
  silent-failure path retry on next launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:33:23 -05:00
Trey t 3c5600f562 Fix textbook section missing on installs that predate bundled JSON
Earlier launches (before cd491bd bundled textbook_data.json) ran
seedTextbookData, got "not bundled — skipping", and still bumped
UserDefaults textbookDataVersion to 9. Subsequent launches then
short-circuited in refreshTextbookDataIfNeeded and never re-seeded,
so ZTEXTBOOKCHAPTER stayed empty and the Course tab hid the section.

- seedTextbookData now returns Bool (true only when chapters inserted).
- Both call sites only write the version key on success, so a missing
  or unparseable bundle no longer locks us out of future retries.
- Bumped textbookDataVersion to 10 to force existing installs to
  re-seed on next launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:39:47 -05:00
Trey T 5f90a01314 Render textbook vocab as paired Spanish→English grid
Previously the chapter reader showed vocab tables as a flat list of OCR
lines — because Vision reads columns top-to-bottom, the Spanish column
appeared as one block followed by the English column, making pairings
illegible.

Now every vocab table renders as a 2-column grid with Spanish on the
left and English on the right. Supporting changes:

- New ocr_all_vocab.swift: bounding-box OCR over all 931 vocab images,
  cluster lines into rows by Y-coordinate, split rows by largest X-gap,
  detect 2- / 3- / 4-column layouts automatically. ~2800 pairs extracted
  this pass vs ~1100 from the old block-alternation heuristic.
- merge_pdf_into_book.py now prefers bounding-box pairs when present,
  falls back to the heuristic, embeds the resulting pairs as
  vocab_table.cards in book.json.
- DataLoader passes cards through to TextbookBlock on seed.
- TextbookChapterView renders cards via SwiftUI Grid (2 cols).
- fix_vocab.py quarantine rule relaxed — only mis-pairs where both
  sides are clearly the same language are removed. "unknown" sides
  stay (bbox pipeline already oriented them correctly).

Textbook card count jumps from 1044 → 3118 active pairs.
textbookDataVersion bumped to 9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:58:41 -05:00
Trey T cd491bd695 Bundle textbook JSON so fresh clones build without re-running pipeline
The pbxproj references textbook_data.json and textbook_vocab.json as Copy
Bundle Resources, so xcodebuild fails if they're missing. Committing the
generated output keeps the repo self-sufficient — regenerate via
Conjuga/Scripts/textbook/run_pipeline.sh when content changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:28:45 -05:00
Trey t df96a9e540 Merge branch 'main' of gitea.treytartt.com:admin/Spanish 2026-04-19 15:23:01 -05:00
Trey t c73762ab9f Add tappable word lookup to chat bubbles
Assistant messages now render each word as a button. Tap shows a sheet
with base form, English translation, and part of speech. Dictionary
lookup first; falls back to Foundation Models (@Generable ChatWordInfo)
for words not in the local dictionary. Results cached per-session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:22:59 -05:00
Trey t f809bc2a1d Fix speech recognition crash from audio format mismatch
Switch audio session to .record-only, use nil tap format so the system
picks a compatible format, and route through AVAudioEngine with a 4096
buffer. Avoids the mDataByteSize(0) assertion seen on some devices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:22:45 -05:00
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
Trey t 5ba76a947b Bump courseDataVersion to 7 after merging Gitea course data updates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:41:55 -05:00
Trey t bb596b19bd Merge branch 'main' of gitea.treytartt.com:admin/Spanish 2026-04-16 08:40:17 -05:00
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
admin b17fb49d49 Merge pull request 'Add English translations to exceptional yo forms' (#20) from issue/19-exceptional-yo-english into main
Reviewed-on: #20
2026-04-16 08:03:35 -05:00
414 changed files with 190812 additions and 1188 deletions
+20
View File
@@ -34,3 +34,23 @@ Pods/
screens/ screens/
conjugato/ conjugato/
conjuu-es/ conjuu-es/
# Video scraping pipeline (kept locally for reruns, not committed)
scrape/
*.webm
*.mp4
*.mkv
# Third-party textbook sources (not redistributable)
*.pdf
*.epub
epub_extract/
# Textbook extraction artifacts — regenerate locally via run_pipeline.sh.
# Scripts are committed; their generated outputs are not.
Conjuga/Scripts/textbook/*.json
Conjuga/Scripts/textbook/review.html
Conjuga/Scripts/textbook/paired_vocab_llm/
# Note: the app-bundle copies (Conjuga/Conjuga/textbook_{data,vocab}.json)
# ARE committed so `xcodebuild` works on a fresh clone without first running
# the pipeline. They're regenerated from the scripts when content changes.
+5
View File
@@ -0,0 +1,5 @@
# Project rules
## Git
- **Never run `git commit` or `git push` without an explicit request from the user in the current turn.** File edits are fine; committing and pushing are not. Wait to be told.
+251 -97
View File
@@ -8,59 +8,103 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */; }; 00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */; };
04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D232CDA43CC9218D748BA121 /* ClozeView.swift */; };
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */; };
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; }; 0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; };
0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */; };
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */; };
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */; };
13A649C50A2B72662F92F0BD /* VocabSessionQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */; };
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; }; 13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; };
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */; };
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; }; 1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; };
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.swift */; }; 1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.swift */; };
20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */; };
218E982FC4267949F82AABAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = 4A4D7B02884EBA9ACD93F0FD /* SharedModels */; }; 218E982FC4267949F82AABAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = 4A4D7B02884EBA9ACD93F0FD /* SharedModels */; };
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */; }; 261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */; };
27BA7FA9356467846A07697D /* TypingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10C16AA6022E4742898745CE /* TypingView.swift */; }; 27BA7FA9356467846A07697D /* TypingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10C16AA6022E4742898745CE /* TypingView.swift */; };
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ADC600530309A9B147A663 /* IrregularHighlightText.swift */; }; 28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ADC600530309A9B147A663 /* IrregularHighlightText.swift */; };
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */ = {isa = PBXBuildFile; fileRef = BC273716CD14A99EFF8206CA /* course_data.json */; }; 2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */ = {isa = PBXBuildFile; fileRef = BC273716CD14A99EFF8206CA /* course_data.json */; };
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */; }; 2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */; };
2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5F851F5C2C71C293DD9938 /* BookVoicePickerSheet.swift */; };
33BFEC0F0DEFC8A0E1FD8009 /* BookSpeechController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A661ADF1141176EE96774138 /* BookSpeechController.swift */; };
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F842EB5E566C74658D918BB /* HandwritingView.swift */; }; 33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F842EB5E566C74658D918BB /* HandwritingView.swift */; };
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */; }; 352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */; };
354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */; };
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; }; 35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; };
362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */ = {isa = PBXBuildFile; productRef = 08D6313690BEE4E2F18EADC3 /* YouTubeKit */; };
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; }; 36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; };
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; }; 377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; };
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; }; 39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; };
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D974250C396589656B8443 /* HandwritingCanvas.swift */; }; 3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D974250C396589656B8443 /* HandwritingCanvas.swift */; };
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */; }; 4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */; };
419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */; };
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; }; 46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; };
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */; };
4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; }; 4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; };
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */; };
4E00225D668FDFA3026B7627 /* BookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF3475931F1AD16054741E65 /* BookChapterListView.swift */; };
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */; }; 50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */; };
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA01795655C444795577A22 /* LyricsConfirmationView.swift */; }; 519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA01795655C444795577A22 /* LyricsConfirmationView.swift */; };
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */; }; 51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */; };
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; }; 53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; };
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; }; 5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2ACC4C35491174257770941 /* VerbReviewStore.swift */; };
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; }; 5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; };
5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */; };
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; }; 60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
61328552866DE185B15011A9 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15AC27B1E3D332709657F20B /* StoryLibraryView.swift */; };
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; }; 615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; };
64E08FBC4B188B332F8039FD /* BookReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDD4AF96186662567525F8C4 /* BookReaderView.swift */; };
65382875879BD537F5358381 /* BookLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */; };
6951332583F08DA6841F09FC /* VocabStudyGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */; };
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; }; 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */; };
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; }; 6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; }; 6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; };
6F7BE3533FAF4DE1D514AA7C /* ExtraStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */; };
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; }; 728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; };
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; }; 760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; };
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D95887B18216FCA71643D6 /* VocabReviewView.swift */; };
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */; }; 7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */; };
81E4DB9F64F3FF3AB8BCB03A /* TextbookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */; };
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; }; 81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; };
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; }; 82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; };
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; }; 84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; };
8B516215E0842189DEA0DBB1 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */; };
8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79576893566932D2BE207528 /* ChatView.swift */; };
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */; }; 8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */; };
8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */; };
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BE2A08EC694FF784ED5575 /* DictionaryService.swift */; };
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; }; 943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; };
97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */ = {isa = PBXBuildFile; fileRef = 3644B5ED77F29A65877D926A /* reflexive_verbs.json */; };
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; }; 97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; };
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */ = {isa = PBXBuildFile; fileRef = A6EC7C278E4287D91A0DB355 /* youtube_videos.md */; };
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; }; 9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */; };
A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */; };
A651D3E1584A34472BCE53B5 /* textbook_vocab.json in Resources */ = {isa = PBXBuildFile; fileRef = 3540936F058728CFD87B1A1E /* textbook_vocab.json */; };
A7DF435F99E66E067F2B33E1 /* ListeningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D1904DF07E0A6816134CF3 /* ListeningView.swift */; };
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; }; A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; }; AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; };
ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96C065B8787DEC6818E497 /* ConversationService.swift */; };
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */; };
ACE9D8B3116757B5D6F0F766 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713F23A9C2935408B136C7C7 /* StoryGenerator.swift */; };
AE156A84B4ECDB1A4A38CE88 /* ExtraStudyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */; };
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */; };
B10A324C06F0957DDE2233F8 /* TextbookChapterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39908548430FDF01D76201FB /* TextbookChapterView.swift */; };
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */; }; B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */; };
B48C0015BE53279B0631C2D7 /* ChatLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648436F8326CF95777E2FA58 /* ChatLibraryView.swift */; };
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */; };
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; }; BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; };
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = BCCBABD74CADDB118179D8E9 /* SharedModels */; }; BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = BCCBABD74CADDB118179D8E9 /* SharedModels */; };
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C359C051FB157EF447561405 /* PracticeViewModel.swift */; }; C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C359C051FB157EF447561405 /* PracticeViewModel.swift */; };
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5983A534E4836F30B5281ACB /* MainTabView.swift */; }; C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5983A534E4836F30B5281ACB /* MainTabView.swift */; };
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */; }; C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */; };
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */; }; C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */; };
C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */; };
C8C3880535008764B7117049 /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADCA82DDD34DF36D59BB283 /* DataLoader.swift */; }; C8C3880535008764B7117049 /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADCA82DDD34DF36D59BB283 /* DataLoader.swift */; };
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */; }; CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */; };
CC886125F8ECE72D1AAD4861 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A48474D969CEF5F573DF09B /* StoryReaderView.swift */; };
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */; }; CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */; };
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; }; D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; };
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4B5204F6B8647C816814F0 /* SyncToast.swift */; }; D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4B5204F6B8647C816814F0 /* SyncToast.swift */; };
@@ -70,30 +114,18 @@
DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */; }; DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */; };
DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777C696A841803D5B775B678 /* ReferenceStore.swift */; }; DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777C696A841803D5B775B678 /* ReferenceStore.swift */; };
E3D9D82E54E37F9D38103FB9 /* book_olly-vol2.json in Resources */ = {isa = PBXBuildFile; fileRef = EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */; };
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; }; E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; };
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */; }; E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */; };
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; }; E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; };
ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */; }; ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */; };
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833516C5D57F164C8660A479 /* CourseView.swift */; }; F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833516C5D57F164C8660A479 /* CourseView.swift */; };
F15E2E02DD6261323BB75789 /* textbook_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 539736EB2AB8D149ED0F9C39 /* textbook_data.json */; };
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */ = {isa = PBXBuildFile; fileRef = 6658C35E454C137B53FC05A4 /* youtube_videos.json */; };
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; }; F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; };
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; }; F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; };
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */; }; FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */; };
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */; }; FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */; };
8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327659ABFD524514B6D2D505 /* StoryGenerator.swift */; };
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */; };
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8B6081226847E0A0A174BC /* StoryReaderView.swift */; };
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */; };
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */; };
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3698CE7ACF148318615293E /* VocabReviewView.swift */; };
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A649B04B8B3C49419AD9219C /* ClozeView.swift */; };
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */; };
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */; };
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D535EF6988A24B47B70209A2 /* PronunciationService.swift */; };
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B2179562E54E148C98219D /* ListeningView.swift */; };
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10603F454E54341AA4B9931 /* ConversationService.swift */; };
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5667AA04211A449A9150BD28 /* ChatLibraryView.swift */; };
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */; };
8510085D78E248D885181E80 /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -121,26 +153,40 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbExampleGenerator.swift; sourceTree = "<group>"; };
0313D24F96E6A0039C34341F /* DailyLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyLog.swift; sourceTree = "<group>"; }; 0313D24F96E6A0039C34341F /* DailyLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyLog.swift; sourceTree = "<group>"; };
07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = "<group>"; };
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewCard.swift; sourceTree = "<group>"; }; 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewCard.swift; sourceTree = "<group>"; };
102F0E136CDFF8CED710210F /* TensePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TensePill.swift; sourceTree = "<group>"; }; 102F0E136CDFF8CED710210F /* TensePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TensePill.swift; sourceTree = "<group>"; };
10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = "<group>"; }; 10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = "<group>"; };
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = "<group>"; }; 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = "<group>"; };
15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabMultipleChoicePracticeView.swift; sourceTree = "<group>"; };
15AC27B1E3D332709657F20B /* StoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryLibraryView.swift; sourceTree = "<group>"; };
16C1F74196C3C5628953BE3F /* Conjuga.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Conjuga.app; sourceTree = BUILT_PRODUCTS_DIR; }; 16C1F74196C3C5628953BE3F /* Conjuga.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Conjuga.app; sourceTree = BUILT_PRODUCTS_DIR; };
180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = "<group>"; }; 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = "<group>"; };
18AC3C548BDB9EF8701BE64C /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; }; 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusMonitor.swift; sourceTree = "<group>"; }; 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusMonitor.swift; sourceTree = "<group>"; };
195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressWidget.swift; sourceTree = "<group>"; }; 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressWidget.swift; sourceTree = "<group>"; };
1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekProgressWidget.swift; sourceTree = "<group>"; }; 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekProgressWidget.swift; sourceTree = "<group>"; };
1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureReferenceView.swift; sourceTree = "<group>"; };
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementService.swift; sourceTree = "<group>"; }; 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementService.swift; sourceTree = "<group>"; };
1C4B5204F6B8647C816814F0 /* SyncToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncToast.swift; sourceTree = "<group>"; }; 1C4B5204F6B8647C816814F0 /* SyncToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncToast.swift; sourceTree = "<group>"; };
1C5F851F5C2C71C293DD9938 /* BookVoicePickerSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookVoicePickerSheet.swift; sourceTree = "<group>"; };
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeView.swift; sourceTree = "<group>"; }; 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeView.swift; sourceTree = "<group>"; };
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaApp.swift; sourceTree = "<group>"; }; 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaApp.swift; sourceTree = "<group>"; };
1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = "<group>"; }; 1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = "<group>"; };
20D1904DF07E0A6816134CF3 /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.swift; sourceTree = "<group>"; };
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExtraStudyStore.swift; sourceTree = "<group>"; };
2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = "<group>"; }; 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = "<group>"; };
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; }; 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; };
30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; }; 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; };
340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookLibraryView.swift; sourceTree = "<group>"; };
34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; };
3540936F058728CFD87B1A1E /* textbook_vocab.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_vocab.json; sourceTree = "<group>"; };
3644B5ED77F29A65877D926A /* reflexive_verbs.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = reflexive_verbs.json; sourceTree = "<group>"; };
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingRecognizer.swift; sourceTree = "<group>"; }; 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingRecognizer.swift; sourceTree = "<group>"; };
39908548430FDF01D76201FB /* TextbookChapterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterView.swift; sourceTree = "<group>"; };
3A96C065B8787DEC6818E497 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.swift; sourceTree = "<group>"; };
3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveContainer.swift; sourceTree = "<group>"; }; 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveContainer.swift; sourceTree = "<group>"; };
3BC3247457109FC6BF00D85B /* TenseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseInfo.swift; sourceTree = "<group>"; }; 3BC3247457109FC6BF00D85B /* TenseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseInfo.swift; sourceTree = "<group>"; };
3CC1AD23158CBABBB753FA1E /* ConjugaWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ConjugaWidget.entitlements; sourceTree = "<group>"; }; 3CC1AD23158CBABBB753FA1E /* ConjugaWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ConjugaWidget.entitlements; sourceTree = "<group>"; };
@@ -149,69 +195,86 @@
42ADC600530309A9B147A663 /* IrregularHighlightText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrregularHighlightText.swift; sourceTree = "<group>"; }; 42ADC600530309A9B147A663 /* IrregularHighlightText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrregularHighlightText.swift; sourceTree = "<group>"; };
43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedWidget.swift; sourceTree = "<group>"; }; 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedWidget.swift; sourceTree = "<group>"; };
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchService.swift; sourceTree = "<group>"; }; 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchService.swift; sourceTree = "<group>"; };
496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterListView.swift; sourceTree = "<group>"; };
49E3AD244327CBF24B7A2752 /* SpeechService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechService.swift; sourceTree = "<group>"; }; 49E3AD244327CBF24B7A2752 /* SpeechService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechService.swift; sourceTree = "<group>"; };
4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = "<group>"; };
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNote.swift; sourceTree = "<group>"; }; 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNote.swift; sourceTree = "<group>"; };
4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadService.swift; sourceTree = "<group>"; };
539736EB2AB8D149ED0F9C39 /* textbook_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_data.json; sourceTree = "<group>"; };
58394296923991E56BAC2B02 /* LyricsReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsReaderView.swift; sourceTree = "<group>"; }; 58394296923991E56BAC2B02 /* LyricsReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsReaderView.swift; sourceTree = "<group>"; };
5983A534E4836F30B5281ACB /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; }; 5983A534E4836F30B5281ACB /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = "<group>"; }; 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = "<group>"; };
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRSEngine.swift; sourceTree = "<group>"; }; 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRSEngine.swift; sourceTree = "<group>"; };
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekTestView.swift; sourceTree = "<group>"; }; 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekTestView.swift; sourceTree = "<group>"; };
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; };
626873572466403C0288090D /* QuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizType.swift; sourceTree = "<group>"; }; 626873572466403C0288090D /* QuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizType.swift; sourceTree = "<group>"; };
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = "<group>"; }; 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = "<group>"; };
648436F8326CF95777E2FA58 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = "<group>"; };
6658C35E454C137B53FC05A4 /* youtube_videos.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = youtube_videos.json; sourceTree = "<group>"; };
69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; }; 69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; };
6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabFlashcardPracticeView.swift; sourceTree = "<group>"; };
6A48474D969CEF5F573DF09B /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; };
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = "<group>"; }; 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = "<group>"; };
70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchView.swift; sourceTree = "<group>"; }; 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchView.swift; sourceTree = "<group>"; };
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTableView.swift; sourceTree = "<group>"; }; 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTableView.swift; sourceTree = "<group>"; };
713F23A9C2935408B136C7C7 /* StoryGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGenerator.swift; sourceTree = "<group>"; };
72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewWordIntent.swift; sourceTree = "<group>"; }; 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewWordIntent.swift; sourceTree = "<group>"; };
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBuilderView.swift; sourceTree = "<group>"; }; 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBuilderView.swift; sourceTree = "<group>"; };
76BE2A08EC694FF784ED5575 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = "<group>"; };
777C696A841803D5B775B678 /* ReferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceStore.swift; sourceTree = "<group>"; }; 777C696A841803D5B775B678 /* ReferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceStore.swift; sourceTree = "<group>"; };
79576893566932D2BE207528 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
7E6AF62A3A949630E067DC22 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 7E6AF62A3A949630E067DC22 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
80D974250C396589656B8443 /* HandwritingCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingCanvas.swift; sourceTree = "<group>"; }; 80D974250C396589656B8443 /* HandwritingCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingCanvas.swift; sourceTree = "<group>"; };
833516C5D57F164C8660A479 /* CourseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseView.swift; sourceTree = "<group>"; }; 833516C5D57F164C8660A479 /* CourseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseView.swift; sourceTree = "<group>"; };
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = "<group>"; }; 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = "<group>"; };
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; }; 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; };
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookExerciseView.swift; sourceTree = "<group>"; };
86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoActionsView.swift; sourceTree = "<group>"; };
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = "<group>"; }; 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = "<group>"; };
8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExtraStudyView.swift; sourceTree = "<group>"; };
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = "<group>"; }; 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = "<group>"; };
8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = "<group>"; }; 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = "<group>"; };
940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReflexiveVerbStore.swift; sourceTree = "<group>"; };
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; }; 9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; };
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A2ACC4C35491174257770941 /* VerbReviewStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VerbReviewStore.swift; sourceTree = "<group>"; };
A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GuideCrossLinks.swift; sourceTree = "<group>"; };
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; }; A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; };
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; }; A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; };
A661ADF1141176EE96774138 /* BookSpeechController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookSpeechController.swift; sourceTree = "<group>"; };
A6EC7C278E4287D91A0DB355 /* youtube_videos.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = youtube_videos.md; sourceTree = "<group>"; };
A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = "<group>"; };
AC34396050805693AA4AC582 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; AC34396050805693AA4AC582 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeVideoStore.swift; sourceTree = "<group>"; };
B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerChecker.swift; sourceTree = "<group>"; };
BC273716CD14A99EFF8206CA /* course_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = course_data.json; sourceTree = "<group>"; }; BC273716CD14A99EFF8206CA /* course_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = course_data.json; sourceTree = "<group>"; };
BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = "<group>"; }; C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = "<group>"; };
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; }; CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; };
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; }; CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; };
D232CDA43CC9218D748BA121 /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; };
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; }; D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; };
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; }; DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; };
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; }; DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; };
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; }; DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; };
DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; }; DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; };
DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabStudyGroup.swift; sourceTree = "<group>"; };
E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = "<group>"; }; E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = "<group>"; };
E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = "<group>"; }; E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = "<group>"; };
E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; }; E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; };
E8D95887B18216FCA71643D6 /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = "<group>"; };
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = "<group>"; }; E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = "<group>"; };
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */ = {isa = PBXFileReference; includeInIndex = 1; path = "book_olly-vol2.json"; sourceTree = "<group>"; };
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbExampleCache.swift; sourceTree = "<group>"; };
EDD4AF96186662567525F8C4 /* BookReaderView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookReaderView.swift; sourceTree = "<group>"; };
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; };
F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemChangeConjugationView.swift; sourceTree = "<group>"; };
FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadedVideosView.swift; sourceTree = "<group>"; };
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; }; FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; };
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; }; FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabSessionQueue.swift; sourceTree = "<group>"; };
327659ABFD524514B6D2D505 /* StoryGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGenerator.swift; sourceTree = "<group>"; }; FF3475931F1AD16054741E65 /* BookChapterListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookChapterListView.swift; sourceTree = "<group>"; };
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryLibraryView.swift; sourceTree = "<group>"; };
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; };
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = "<group>"; };
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = "<group>"; };
D3698CE7ACF148318615293E /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = "<group>"; };
A649B04B8B3C49419AD9219C /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; };
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; };
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = "<group>"; };
D535EF6988A24B47B70209A2 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = "<group>"; };
02B2179562E54E148C98219D /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.swift; sourceTree = "<group>"; };
E10603F454E54341AA4B9931 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.swift; sourceTree = "<group>"; };
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = "<group>"; };
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureReferenceView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -228,6 +291,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */, BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */,
362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -243,11 +307,16 @@
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */, 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */,
BC273716CD14A99EFF8206CA /* course_data.json */, BC273716CD14A99EFF8206CA /* course_data.json */,
7E6AF62A3A949630E067DC22 /* Info.plist */, 7E6AF62A3A949630E067DC22 /* Info.plist */,
3644B5ED77F29A65877D926A /* reflexive_verbs.json */,
539736EB2AB8D149ED0F9C39 /* textbook_data.json */,
3540936F058728CFD87B1A1E /* textbook_vocab.json */,
6658C35E454C137B53FC05A4 /* youtube_videos.json */,
A6EC7C278E4287D91A0DB355 /* youtube_videos.md */,
353C5DE41FD410FA82E3AED7 /* Models */, 353C5DE41FD410FA82E3AED7 /* Models */,
1994867BC8E985795A172854 /* Services */, 1994867BC8E985795A172854 /* Services */,
BFC1AEBE02CE22E6474FFEA6 /* Utilities */,
3C75490F53C34A37084FF478 /* ViewModels */, 3C75490F53C34A37084FF478 /* ViewModels */,
A81CA75762B08D35D5B7A44D /* Views */, A81CA75762B08D35D5B7A44D /* Views */,
EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */,
); );
path = Conjuga; path = Conjuga;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -255,8 +324,9 @@
0931AEB5B728C3A03F06A1CA /* Settings */ = { 0931AEB5B728C3A03F06A1CA /* Settings */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */,
1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */,
BCCC95A95581458E068E0484 /* SettingsView.swift */, BCCC95A95581458E068E0484 /* SettingsView.swift */,
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */,
); );
path = Settings; path = Settings;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -274,28 +344,48 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */, 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */,
B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */,
3A96C065B8787DEC6818E497 /* ConversationService.swift */,
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */, DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */, DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
76BE2A08EC694FF784ED5575 /* DictionaryService.swift */,
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */, 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */, 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */, 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
E10603F454E54341AA4B9931 /* ConversationService.swift */, 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */,
D535EF6988A24B47B70209A2 /* PronunciationService.swift */,
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */,
327659ABFD524514B6D2D505 /* StoryGenerator.swift */,
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */,
777C696A841803D5B775B678 /* ReferenceStore.swift */, 777C696A841803D5B775B678 /* ReferenceStore.swift */,
940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */,
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */, CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
49E3AD244327CBF24B7A2752 /* SpeechService.swift */, 49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */, 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */,
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */, A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */,
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */, E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */,
713F23A9C2935408B136C7C7 /* StoryGenerator.swift */,
4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */,
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */, 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */,
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */,
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */,
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */,
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */, D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */,
A661ADF1141176EE96774138 /* BookSpeechController.swift */,
A2ACC4C35491174257770941 /* VerbReviewStore.swift */,
FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */,
); );
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
1ECAF79E2138DF73BB1F6403 /* Vocab */ = {
isa = PBXGroup;
children = (
6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */,
15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */,
);
name = Vocab;
path = Vocab;
sourceTree = "<group>";
};
29F9EEAED5A6969FEDEAE227 /* Onboarding */ = { 29F9EEAED5A6969FEDEAE227 /* Onboarding */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -308,6 +398,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0313D24F96E6A0039C34341F /* DailyLog.swift */, 0313D24F96E6A0039C34341F /* DailyLog.swift */,
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */,
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */, 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */,
626873572466403C0288090D /* QuizType.swift */, 626873572466403C0288090D /* QuizType.swift */,
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */, 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */,
@@ -315,8 +406,9 @@
3BC3247457109FC6BF00D85B /* TenseInfo.swift */, 3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
DAFE27F29412021AEC57E728 /* TestResult.swift */, DAFE27F29412021AEC57E728 /* TestResult.swift */,
E536AD1180FE10576EAC884A /* UserProgress.swift */, E536AD1180FE10576EAC884A /* UserProgress.swift */,
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */, A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */,
); DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */,
);
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -341,6 +433,16 @@
path = ViewModels; path = ViewModels;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
43E4D263B0AF47E401A51601 /* Stories */ = {
isa = PBXGroup;
children = (
15AC27B1E3D332709657F20B /* StoryLibraryView.swift */,
A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */,
6A48474D969CEF5F573DF09B /* StoryReaderView.swift */,
);
path = Stories;
sourceTree = "<group>";
};
4B183AB0C56BC2EC302531E7 /* ConjugaWidget */ = { 4B183AB0C56BC2EC302531E7 /* ConjugaWidget */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -361,52 +463,48 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */, 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */,
D232CDA43CC9218D748BA121 /* ClozeView.swift */,
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */, 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */,
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */, 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */,
1F842EB5E566C74658D918BB /* HandwritingView.swift */, 1F842EB5E566C74658D918BB /* HandwritingView.swift */,
20D1904DF07E0A6816134CF3 /* ListeningView.swift */,
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */, DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */, 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */, 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
D3698CE7ACF148318615293E /* VocabReviewView.swift */,
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */, 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
10C16AA6022E4742898745CE /* TypingView.swift */, 10C16AA6022E4742898745CE /* TypingView.swift */,
E8D95887B18216FCA71643D6 /* VocabReviewView.swift */,
8FB89F19B33894DDF27C8EC2 /* Chat */,
895E547BEFB5D0FBF676BE33 /* Lyrics */, 895E547BEFB5D0FBF676BE33 /* Lyrics */,
8A1DED0596E04DDE9536A9A9 /* Stories */, 43E4D263B0AF47E401A51601 /* Stories */,
DFD75E32A53845A693D98F48 /* Chat */, 74AC8A0D381958D2A14316C3 /* Books */,
02B2179562E54E148C98219D /* ListeningView.swift */, 1ECAF79E2138DF73BB1F6403 /* Vocab */,
A649B04B8B3C49419AD9219C /* ClozeView.swift */, );
);
path = Practice; path = Practice;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
74AC8A0D381958D2A14316C3 /* Books */ = {
isa = PBXGroup;
children = (
340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */,
FF3475931F1AD16054741E65 /* BookChapterListView.swift */,
EDD4AF96186662567525F8C4 /* BookReaderView.swift */,
1C5F851F5C2C71C293DD9938 /* BookVoicePickerSheet.swift */,
);
name = Books;
path = Books;
sourceTree = "<group>";
};
8102F7FA5BFE6D38B2212AD3 /* Guide */ = { 8102F7FA5BFE6D38B2212AD3 /* Guide */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */,
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */, 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */,
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */, 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */,
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */, 86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */,
); );
path = Guide; path = Guide;
sourceTree = "<group>"; sourceTree = "<group>";
};
DFD75E32A53845A693D98F48 /* Chat */ = {
isa = PBXGroup;
children = (
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */,
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */,
);
path = Chat;
sourceTree = "<group>";
};
8A1DED0596E04DDE9536A9A9 /* Stories */ = {
isa = PBXGroup;
children = (
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */,
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */,
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */,
);
path = Stories;
sourceTree = "<group>";
}; };
895E547BEFB5D0FBF676BE33 /* Lyrics */ = { 895E547BEFB5D0FBF676BE33 /* Lyrics */ = {
isa = PBXGroup; isa = PBXGroup;
@@ -419,6 +517,15 @@
path = Lyrics; path = Lyrics;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
8FB89F19B33894DDF27C8EC2 /* Chat */ = {
isa = PBXGroup;
children = (
648436F8326CF95777E2FA58 /* ChatLibraryView.swift */,
79576893566932D2BE207528 /* ChatView.swift */,
);
path = Chat;
sourceTree = "<group>";
};
A591A3B6F1F13D23D68D7A9D = { A591A3B6F1F13D23D68D7A9D = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -457,23 +564,21 @@
BE5A40BAC9DD6884C58A2096 /* Course */ = { BE5A40BAC9DD6884C58A2096 /* Course */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */,
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */, 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */,
833516C5D57F164C8660A479 /* CourseView.swift */, 833516C5D57F164C8660A479 /* CourseView.swift */,
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */, 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */,
496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */,
39908548430FDF01D76201FB /* TextbookChapterView.swift */,
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */,
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */, 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */, 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */, 8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */,
); );
path = Course; path = Course;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
BFC1AEBE02CE22E6474FFEA6 /* Utilities */ = {
isa = PBXGroup;
children = (
);
path = Utilities;
sourceTree = "<group>";
};
F605D24E5EA11065FD18AF7E /* Products */ = { F605D24E5EA11065FD18AF7E /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -511,6 +616,7 @@
name = Conjuga; name = Conjuga;
packageProductDependencies = ( packageProductDependencies = (
BCCBABD74CADDB118179D8E9 /* SharedModels */, BCCBABD74CADDB118179D8E9 /* SharedModels */,
08D6313690BEE4E2F18EADC3 /* YouTubeKit */,
); );
productName = Conjuga; productName = Conjuga;
productReference = 16C1F74196C3C5628953BE3F /* Conjuga.app */; productReference = 16C1F74196C3C5628953BE3F /* Conjuga.app */;
@@ -555,7 +661,6 @@
}; };
}; };
buildConfigurationList = F011A5DA3101F6F7CA7D2D95 /* Build configuration list for PBXProject "Conjuga" */; buildConfigurationList = F011A5DA3101F6F7CA7D2D95 /* Build configuration list for PBXProject "Conjuga" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en; developmentRegion = en;
hasScannedForEncodings = 0; hasScannedForEncodings = 0;
knownRegions = ( knownRegions = (
@@ -565,9 +670,11 @@
mainGroup = A591A3B6F1F13D23D68D7A9D; mainGroup = A591A3B6F1F13D23D68D7A9D;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = ( packageReferences = (
E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */,
548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */, 548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = F605D24E5EA11065FD18AF7E /* Products */;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
targets = ( targets = (
@@ -585,6 +692,12 @@
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */, F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */,
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */, CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */,
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */, 2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */,
97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */,
F15E2E02DD6261323BB75789 /* textbook_data.json in Resources */,
A651D3E1584A34472BCE53B5 /* textbook_vocab.json in Resources */,
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */,
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */,
E3D9D82E54E37F9D38103FB9 /* book_olly-vol2.json in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -597,8 +710,14 @@
files = ( files = (
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */, 97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */,
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */, 261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */,
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */,
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */, CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */,
B48C0015BE53279B0631C2D7 /* ChatLibraryView.swift in Sources */,
8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */,
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */,
04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */,
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */, C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */,
ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */,
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */, C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */,
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */, 8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */,
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */, F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */,
@@ -607,8 +726,13 @@
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */, 35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */,
C8C3880535008764B7117049 /* DataLoader.swift in Sources */, C8C3880535008764B7117049 /* DataLoader.swift in Sources */,
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */, 84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */,
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */,
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */,
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */,
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */, D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */,
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */, A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */,
8B516215E0842189DEA0DBB1 /* GrammarExercise.swift in Sources */,
8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */,
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */, 377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */,
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */, F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */,
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */, 760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */,
@@ -616,6 +740,7 @@
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */, E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */, 33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */, 28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
A7DF435F99E66E067F2B33E1 /* ListeningView.swift in Sources */,
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */, 519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */,
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */, B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */,
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */, 615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */,
@@ -628,8 +753,10 @@
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */, 352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */,
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */, 1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */,
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */, C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */,
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */,
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */, 53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */,
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */, DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */,
A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */,
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */, FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */,
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */, 728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */,
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */, 51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */,
@@ -637,39 +764,50 @@
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */, 60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */,
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */, D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */,
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */, 9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */,
20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */,
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */, E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */,
ACE9D8B3116757B5D6F0F766 /* StoryGenerator.swift in Sources */,
61328552866DE185B15011A9 /* StoryLibraryView.swift in Sources */,
0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */,
CC886125F8ECE72D1AAD4861 /* StoryReaderView.swift in Sources */,
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */, 36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */,
5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */,
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */, 6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */,
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */, D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */,
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */, AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */,
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */, 0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */,
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */, 46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */,
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */, D7456B289D135CEB3A15122B /* TestResult.swift in Sources */,
81E4DB9F64F3FF3AB8BCB03A /* TextbookChapterListView.swift in Sources */,
B10A324C06F0957DDE2233F8 /* TextbookChapterView.swift in Sources */,
354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */,
27BA7FA9356467846A07697D /* TypingView.swift in Sources */, 27BA7FA9356467846A07697D /* TypingView.swift in Sources */,
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */, 943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */,
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */, 50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */,
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */,
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */,
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */, 81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */,
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */,
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */, 4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */,
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */, 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */,
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */, E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */, 05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */,
8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */, AE156A84B4ECDB1A4A38CE88 /* ExtraStudyStore.swift in Sources */,
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */, 6F7BE3533FAF4DE1D514AA7C /* ExtraStudyView.swift in Sources */,
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */, 65382875879BD537F5358381 /* BookLibraryView.swift in Sources */,
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */, 4E00225D668FDFA3026B7627 /* BookChapterListView.swift in Sources */,
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */, 64E08FBC4B188B332F8039FD /* BookReaderView.swift in Sources */,
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */, 33BFEC0F0DEFC8A0E1FD8009 /* BookSpeechController.swift in Sources */,
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */, 2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */,
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */, C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */,
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */, 419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */,
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */, 12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */,
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */, 5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */,
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */, 13A649C50A2B72662F92F0BD /* VocabSessionQueue.swift in Sources */,
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */, 6951332583F08DA6841F09FC /* VocabStudyGroup.swift in Sources */,
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */, );
8510085D78E248D885181E80 /* FeatureReferenceView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
217A29BCEDD9D44B6DD85AF6 /* Sources */ = { 217A29BCEDD9D44B6DD85AF6 /* Sources */ = {
@@ -941,7 +1079,23 @@
}; };
/* End XCLocalSwiftPackageReference section */ /* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */
E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/alexeichhorn/YouTubeKit.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.3.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
08D6313690BEE4E2F18EADC3 /* YouTubeKit */ = {
isa = XCSwiftPackageProductDependency;
package = E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */;
productName = YouTubeKit;
};
4A4D7B02884EBA9ACD93F0FD /* SharedModels */ = { 4A4D7B02884EBA9ACD93F0FD /* SharedModels */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = SharedModels; productName = SharedModels;
@@ -0,0 +1,15 @@
{
"originHash" : "1b6ada17bf1104878f9520a6f7cb3cd84338c0da74dc3761cef075709d7df45d",
"pins" : [
{
"identity" : "youtubekit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/alexeichhorn/YouTubeKit.git",
"state" : {
"revision" : "65be95dbb1dbd749499e0638871568c823822276",
"version" : "0.4.8"
}
}
],
"version" : 3
}
+21 -12
View File
@@ -40,6 +40,9 @@ struct ConjugaApp: App {
@State private var syncMonitor = SyncStatusMonitor() @State private var syncMonitor = SyncStatusMonitor()
@State private var studyTimer = StudyTimerService() @State private var studyTimer = StudyTimerService()
@State private var dictionary = DictionaryService() @State private var dictionary = DictionaryService()
@State private var verbExampleCache = VerbExampleCache()
@State private var reflexiveStore = ReflexiveVerbStore()
@State private var youtubeVideoStore = YouTubeVideoStore()
let localContainer: ModelContainer let localContainer: ModelContainer
let cloudContainer: ModelContainer let cloudContainer: ModelContainer
@@ -67,14 +70,16 @@ struct ConjugaApp: App {
let cloudConfig = ModelConfiguration( let cloudConfig = ModelConfiguration(
"cloud", "cloud",
schema: Schema([ schema: Schema([
ReviewCard.self, CourseReviewCard.self, UserProgress.self, ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self, TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
]), ]),
cloudKitDatabase: .private("iCloud.com.conjuga.app") cloudKitDatabase: .private("iCloud.com.conjuga.app")
) )
cloudContainer = try ModelContainer( cloudContainer = try ModelContainer(
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self, for: ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self, TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
configurations: cloudConfig configurations: cloudConfig
) )
@@ -111,6 +116,9 @@ struct ConjugaApp: App {
.environment(\.cloudModelContextProvider, { cloudContainer.mainContext }) .environment(\.cloudModelContextProvider, { cloudContainer.mainContext })
.environment(studyTimer) .environment(studyTimer)
.environment(dictionary) .environment(dictionary)
.environment(verbExampleCache)
.environment(reflexiveStore)
.environment(youtubeVideoStore)
.task { .task {
let needsSeed = await DataLoader.needsSeeding(container: localContainer) let needsSeed = await DataLoader.needsSeeding(container: localContainer)
if needsSeed { if needsSeed {
@@ -133,6 +141,11 @@ struct ConjugaApp: App {
localContainer: localContainer, localContainer: localContainer,
cloudContainer: cloudContainer cloudContainer: cloudContainer
) )
// Reset a broken streak immediately on launch so the
// dashboard never shows a stale number even if the user
// hasn't navigated to it yet.
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContainer.mainContext)
progress.validateStreakIfStale(context: cloudContainer.mainContext)
WidgetDataService.update( WidgetDataService.update(
localContainer: localContainer, localContainer: localContainer,
cloudContainer: cloudContainer cloudContainer: cloudContainer
@@ -204,20 +217,16 @@ struct ConjugaApp: App {
} }
private static func makeLocalContainer(at url: URL) throws -> ModelContainer { private static func makeLocalContainer(at url: URL) throws -> ModelContainer {
// Built from the single shared model list so the app and the widget
// extension always open the store with an identical schema.
let schema = Schema(SharedStore.localSchemaModels)
let localConfig = ModelConfiguration( let localConfig = ModelConfiguration(
"local", "local",
schema: Schema([ schema: schema,
Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
]),
url: url, url: url,
cloudKitDatabase: .none cloudKitDatabase: .none
) )
return try ModelContainer( return try ModelContainer(for: schema, configurations: localConfig)
for: Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
configurations: localConfig
)
} }
private static func localStoreIsUsable(container: ModelContainer) -> Bool { private static func localStoreIsUsable(container: ModelContainer) -> Bool {
@@ -244,7 +253,7 @@ struct ConjugaApp: App {
/// Clears accumulated stale schema metadata from previous container configurations. /// Clears accumulated stale schema metadata from previous container configurations.
/// Bump the version number to force another reset if the schema changes again. /// Bump the version number to force another reset if the schema changes again.
private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) { private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) {
let resetVersion = 3 // bump: SavedSong moved to cloud container let resetVersion = 5 // bump: Book/BookChapter added to local container
let key = "localStoreResetVersion" let key = "localStoreResetVersion"
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
+6 -10
View File
@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.conjuga.app.refresh</string>
</array>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@@ -22,21 +26,13 @@
<string>1</string> <string>1</string>
<key>LSApplicationCategoryType</key> <key>LSApplicationCategoryType</key>
<string>public.app-category.education</string> <string>public.app-category.education</string>
<key>UILaunchScreen</key>
<dict/>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Conjuga uses speech recognition to check your Spanish pronunciation and transcribe what you say during listening exercises.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Conjuga needs microphone access to record your voice for pronunciation practice.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>fetch</string> <string>fetch</string>
<string>remote-notification</string> <string>remote-notification</string>
</array> </array>
<key>BGTaskSchedulerPermittedIdentifiers</key> <key>UILaunchScreen</key>
<array> <dict/>
<string>com.conjuga.app.refresh</string>
</array>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
+14 -5
View File
@@ -32,10 +32,21 @@ final class DailyLog {
} }
} }
static func dateString(from date: Date) -> String { /// Defensive formatter: explicit POSIX locale + current timezone so date
/// strings can never drift due to locale formatting (e.g. Arabic numerals)
/// or implicit-zone shifts. The string format is timezone-naive
/// `yyyy-MM-dd`, which works because we only ever compare to other
/// strings produced by this same formatter.
private static func makeFormatter() -> DateFormatter {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = .current
formatter.dateFormat = "yyyy-MM-dd" formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date) return formatter
}
static func dateString(from date: Date) -> String {
makeFormatter().string(from: date)
} }
static func todayString() -> String { static func todayString() -> String {
@@ -43,8 +54,6 @@ final class DailyLog {
} }
static func date(from string: String) -> Date? { static func date(from string: String) -> Date? {
let formatter = DateFormatter() makeFormatter().date(from: string)
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: string)
} }
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,108 @@
import Foundation
/// Maps tense guides grammar notes for the Guide tab's cross-link chips.
/// The forward map is the curated source; the reverse map is derived once.
///
/// A tense ID appears here only if at least one grammar note in
/// `GrammarNote.allNotesIncludingGenerated` covers a concept directly tied
/// to that tense (forms, contrast, triggers, choice). Two tenses currently
/// have no aligned notes and don't appear: `ind_pluscuamperfecto` and
/// `ind_preterito_anterior`.
enum GuideCrossLinks {
/// Tense ID ordered grammar note IDs that go deeper on this tense.
/// Order matters the first chip is the most-relevant note for the
/// tense's primary teaching point.
static let relatedNotes: [String: [String]] = [
"ind_presente": [
"present-indicative-conjugation",
"irregular-yo-verbs",
"stem-changing-verbs",
"estar-gerund-progressive",
],
"ind_preterito": [
"preterite-vs-imperfect",
"stem-changing-verbs",
],
"ind_imperfecto": [
"preterite-vs-imperfect",
],
"ind_futuro": [
"future-vs-ir-a",
],
"ind_perfecto": [
"present-perfect-tense",
],
"ind_futuro_perfecto": [
"future-perfect-tense",
],
"cond_presente": [
"conditional-if-clauses",
],
"cond_perfecto": [
"conditional-if-clauses",
],
"subj_presente": [
"subjunctive-triggers",
"irregular-yo-verbs",
"stem-changing-verbs",
],
"subj_imperfecto_1": [
"subjunctive-triggers",
"conditional-if-clauses",
],
"subj_imperfecto_2": [
"subjunctive-triggers",
"conditional-if-clauses",
],
"subj_perfecto": [
"subjunctive-triggers",
],
"subj_pluscuamperfecto_1": [
"subjunctive-triggers",
"conditional-if-clauses",
],
"subj_pluscuamperfecto_2": [
"subjunctive-triggers",
"conditional-if-clauses",
],
"subj_futuro": [
"subjunctive-triggers",
],
"subj_futuro_perfecto": [
"subjunctive-triggers",
],
"imp_afirmativo": [
"commands-imperative",
],
"imp_negativo": [
"commands-imperative",
"subjunctive-triggers",
],
]
/// Grammar note ID tense IDs that point at this note, ordered by the
/// shared `TenseInfo.order` so chips appear in canonical conjugation
/// order.
static let relatedTenses: [String: [String]] = {
var inverse: [String: [String]] = [:]
for (tenseId, noteIds) in relatedNotes {
for noteId in noteIds {
inverse[noteId, default: []].append(tenseId)
}
}
for key in inverse.keys {
inverse[key]?.sort { lhs, rhs in
(TenseInfo.find(lhs)?.order ?? 999) < (TenseInfo.find(rhs)?.order ?? 999)
}
}
return inverse
}()
static func noteIds(forTense tenseId: String) -> [String] {
relatedNotes[tenseId] ?? []
}
static func tenseIds(forNote noteId: String) -> [String] {
relatedTenses[noteId] ?? []
}
}
+27
View File
@@ -55,3 +55,30 @@ final class CourseReviewCard {
self.back = back self.back = back
} }
} }
/// SRS record for verb-level vocab practice (EN ES infinitive recall),
/// separate from per-form `ReviewCard` so a user's vocab progress doesn't
/// collide with conjugation form mastery.
///
/// CloudKit-synced; uniqueness on `id` is enforced via fetch-or-create in
/// `VerbReviewStore` since CloudKit forbids `@Attribute(.unique)`.
@Model
final class VerbReviewCard {
var id: String = ""
var verbId: Int = 0
var easeFactor: Double = 2.5
var interval: Int = 0
var repetitions: Int = 0
var dueDate: Date = Date()
var lastReviewDate: Date?
init(verbId: Int) {
self.id = Self.makeId(verbId: verbId)
self.verbId = verbId
}
static func makeId(verbId: Int) -> String {
"verb-\(verbId)"
}
}
+85
View File
@@ -14,6 +14,7 @@ final class UserProgress {
var selectedLevel: String = "basic" var selectedLevel: String = "basic"
var showVosotros: Bool = true var showVosotros: Bool = true
var autoFillStem: Bool = false var autoFillStem: Bool = false
var showReflexiveVerbsOnly: Bool = false
// Legacy CloudKit array-backed fields retained for migration compatibility. // Legacy CloudKit array-backed fields retained for migration compatibility.
var enabledTenses: [String] = [] var enabledTenses: [String] = []
@@ -21,6 +22,10 @@ final class UserProgress {
var enabledTensesBlob: String = "" var enabledTensesBlob: String = ""
var unlockedBadgesBlob: String = "" var unlockedBadgesBlob: String = ""
// Multi-select level + irregularity filters (Issue #26).
var selectedLevelsBlob: String = ""
var enabledIrregularCategoriesBlob: String = ""
init() {} init() {}
var selectedVerbLevel: VerbLevel { var selectedVerbLevel: VerbLevel {
@@ -44,6 +49,44 @@ final class UserProgress {
} }
} }
/// Levels currently enabled for practice. Multi-select per Issue #26.
/// Setting this also syncs `selectedLevel` to the highest-ranked selection so
/// legacy single-level consumers (widget, AI scenarios, word-of-day) stay consistent.
var selectedVerbLevels: Set<VerbLevel> {
get {
let raw = decodeStringArray(from: selectedLevelsBlob, fallback: [])
let decoded = Set(raw.compactMap(VerbLevel.init(rawValue:)))
if !decoded.isEmpty { return decoded }
// Pre-migration users: treat the single selectedLevel as the set.
if let legacy = VerbLevel(rawValue: selectedLevel) {
return [legacy]
}
return []
}
set {
let sorted = newValue.map(\.rawValue)
selectedLevelsBlob = Self.encodeStringArray(sorted)
selectedLevel = VerbLevel.highest(in: newValue)?.rawValue ?? VerbLevel.basic.rawValue
}
}
/// The single representative level for callers that need one value
/// (word-of-day widget, AI chat/story scenarios). Highest selected level.
var primaryLevel: VerbLevel {
VerbLevel.highest(in: selectedVerbLevels) ?? selectedVerbLevel
}
var enabledIrregularCategories: Set<IrregularSpan.SpanCategory> {
get {
let raw = decodeStringArray(from: enabledIrregularCategoriesBlob, fallback: [])
return Set(raw.compactMap(IrregularSpan.SpanCategory.init(rawValue:)))
}
set {
let sorted = newValue.map(\.rawValue)
enabledIrregularCategoriesBlob = Self.encodeStringArray(sorted)
}
}
func setTenseEnabled(_ tenseId: String, enabled: Bool) { func setTenseEnabled(_ tenseId: String, enabled: Bool) {
var values = Set(enabledTenseIDs) var values = Set(enabledTenseIDs)
if enabled { if enabled {
@@ -54,12 +97,50 @@ final class UserProgress {
enabledTenseIDs = values.sorted() enabledTenseIDs = values.sorted()
} }
func setLevelEnabled(_ level: VerbLevel, enabled: Bool) {
var values = selectedVerbLevels
if enabled {
values.insert(level)
} else {
values.remove(level)
}
selectedVerbLevels = values
}
func setIrregularCategoryEnabled(_ category: IrregularSpan.SpanCategory, enabled: Bool) {
var values = enabledIrregularCategories
if enabled {
values.insert(category)
} else {
values.remove(category)
}
enabledIrregularCategories = values
}
func unlockBadge(_ badgeId: String) { func unlockBadge(_ badgeId: String) {
var values = Set(unlockedBadgeIDs) var values = Set(unlockedBadgeIDs)
values.insert(badgeId) values.insert(badgeId)
unlockedBadgeIDs = values.sorted() unlockedBadgeIDs = values.sorted()
} }
/// Resets `currentStreak` to zero if more than one day has passed since
/// the last recorded activity. Without this check the dashboard keeps
/// displaying a stale streak number for days after the user actually
/// stops practicing the underlying counter only updates on the *next*
/// practice action. Call from app launch and the dashboard's `.task`.
@MainActor
func validateStreakIfStale(today: Date = Date(), context: ModelContext) {
guard !todayDate.isEmpty else { return }
let todayString = DailyLog.dateString(from: today)
if todayDate == todayString { return }
guard let prevDate = DailyLog.date(from: todayDate) else { return }
let diff = Calendar.current.dateComponents([.day], from: prevDate, to: today)
if (diff.day ?? Int.max) > 1 && currentStreak != 0 {
currentStreak = 0
try? context.save()
}
}
func migrateLegacyStorageIfNeeded() { func migrateLegacyStorageIfNeeded() {
if enabledTensesBlob.isEmpty && !enabledTenses.isEmpty { if enabledTensesBlob.isEmpty && !enabledTenses.isEmpty {
enabledTenseIDs = enabledTenses enabledTenseIDs = enabledTenses
@@ -67,6 +148,9 @@ final class UserProgress {
if unlockedBadgesBlob.isEmpty && !unlockedBadges.isEmpty { if unlockedBadgesBlob.isEmpty && !unlockedBadges.isEmpty {
unlockedBadgeIDs = unlockedBadges unlockedBadgeIDs = unlockedBadges
} }
if selectedLevelsBlob.isEmpty, let legacy = VerbLevel(rawValue: selectedLevel) {
selectedVerbLevels = [legacy]
}
} }
private func decodeStringArray(from blob: String, fallback: [String]) -> [String] { private func decodeStringArray(from blob: String, fallback: [String]) -> [String] {
@@ -86,4 +170,5 @@ final class UserProgress {
} }
return string return string
} }
} }
@@ -0,0 +1,89 @@
import Foundation
import SwiftData
/// The user's active vocab-flashcard study set, persisted and CloudKit-synced
/// so the same group of verbs follows them across launches and across devices.
/// A new group is created when the previous one is fully learned.
///
/// CloudKit-synced; uniqueness on `id` is enforced in code (CloudKit forbids
/// `@Attribute(.unique)`). There is one active standard group at a time.
@Model
final class VocabStudyGroup {
var id: String = ""
/// JSON-encoded `[StoredVocabEntry]` the in-session queue (un-graduated
/// verbs only) in order, each with its learning-step state.
var entriesJSON: Data = Data()
var learnedCount: Int = 0
var createdAt: Date = Date()
init(entriesJSON: Data, learnedCount: Int) {
self.id = Self.activeID
self.entriesJSON = entriesJSON
self.learnedCount = learnedCount
self.createdAt = Date()
}
/// Single active standard-session group.
static let activeID = "active-standard"
var entries: [StoredVocabEntry] {
(try? JSONDecoder().decode([StoredVocabEntry].self, from: entriesJSON)) ?? []
}
}
/// One verb's persisted spot in the study group.
struct StoredVocabEntry: Codable {
var verbId: Int
/// Raw value of `VocabSessionQueue.CardState`.
var state: String
}
/// Fetch / persist / clear the active study group. Operates on the cloud
/// context so the group syncs across devices.
struct VocabStudyGroupStore {
let context: ModelContext
/// The current active group, or nil. If duplicate records exist (two
/// devices both created one before sync settled), the newest wins.
func activeGroup() -> VocabStudyGroup? {
let id = VocabStudyGroup.activeID
let descriptor = FetchDescriptor<VocabStudyGroup>(
predicate: #Predicate<VocabStudyGroup> { $0.id == id },
sortBy: [SortDescriptor(\VocabStudyGroup.createdAt, order: .reverse)]
)
return (try? context.fetch(descriptor))?.first
}
/// Write the in-progress group, creating it if needed. If duplicate records
/// exist (two devices created a group before sync settled), the newest is
/// updated and the rest deleted so a single record survives.
func persist(entries: [StoredVocabEntry], learnedCount: Int) {
let data = (try? JSONEncoder().encode(entries)) ?? Data()
let id = VocabStudyGroup.activeID
let descriptor = FetchDescriptor<VocabStudyGroup>(
predicate: #Predicate<VocabStudyGroup> { $0.id == id },
sortBy: [SortDescriptor(\VocabStudyGroup.createdAt, order: .reverse)]
)
let existing = (try? context.fetch(descriptor)) ?? []
if let newest = existing.first {
newest.entriesJSON = data
newest.learnedCount = learnedCount
for duplicate in existing.dropFirst() { context.delete(duplicate) }
} else {
context.insert(VocabStudyGroup(entriesJSON: data, learnedCount: learnedCount))
}
try? context.save()
}
/// Remove the active group (and any duplicates) the set is finished.
func clear() {
let id = VocabStudyGroup.activeID
let descriptor = FetchDescriptor<VocabStudyGroup>(
predicate: #Predicate<VocabStudyGroup> { $0.id == id }
)
for group in (try? context.fetch(descriptor)) ?? [] {
context.delete(group)
}
try? context.save()
}
}
@@ -0,0 +1,10 @@
import Foundation
import SharedModels
/// Thin app-side wrapper around the SharedModels `AnswerGrader`. All logic
/// lives in SharedModels so it can be unit tested.
enum AnswerChecker {
static func grade(userText: String, canonical: String, alternates: [String] = []) -> TextbookGrade {
AnswerGrader.grade(userText: userText, canonical: canonical, alternates: alternates)
}
}
@@ -0,0 +1,222 @@
import AVFoundation
import Foundation
import Observation
/// Drives "read aloud" mode for `BookReaderView`. Wraps an
/// `AVSpeechSynthesizer` with a queue of paragraph utterances and exposes the
/// current paragraph/word index so the view can highlight the active word.
///
/// Skips vocabulary lines (`palabra = meaning`) since the synth pronounces the
/// `=` awkwardly and the bilingual gloss is reference material, not prose.
@MainActor
@Observable
final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
// MARK: - Observable state
private(set) var isReading: Bool = false
private(set) var isPaused: Bool = false
private(set) var currentParagraphIndex: Int? = nil
private(set) var currentWordIndex: Int? = nil
// MARK: - Configuration
var rate: Float = 0.45
var voiceIdentifier: String? = nil
// MARK: - Internals
/// Built on first use, not in `init`. `AVSpeechSynthesizer()` connects to
/// the system speech daemon, so allocating one per `BookReaderView` struct
/// construction (SwiftUI rebuilds the struct on every parent render) is a
/// real cost deferring it keeps controller construction cheap.
@ObservationIgnored
private lazy var synthesizer: AVSpeechSynthesizer = {
let synth = AVSpeechSynthesizer()
synth.delegate = self
return synth
}()
private var queue: [QueueEntry] = []
private var queueCursor: Int = 0
private var audioSessionConfigured = false
private struct QueueEntry {
let paragraphIndex: Int
let text: String
let wordRanges: [Range<String.Index>]
}
override init() {
super.init()
}
// MARK: - Public control
/// Start (or restart) reading the given paragraphs. Indexes in
/// `currentParagraphIndex` are positions in the original `paragraphs`
/// array vocab lines are skipped internally but the visible index space
/// matches what the caller passed.
func start(paragraphs: [String], from startIndex: Int = 0) {
stop()
configureAudioSession()
var entries: [QueueEntry] = []
for (idx, p) in paragraphs.enumerated() where idx >= startIndex {
if Self.isVocabLine(p) { continue }
entries.append(QueueEntry(
paragraphIndex: idx,
text: p,
wordRanges: Self.wordRanges(in: p)
))
}
guard !entries.isEmpty else { return }
queue = entries
queueCursor = 0
isReading = true
isPaused = false
speakCurrent()
}
/// Pause immediately (no word boundary). Use this for tap-to-define so the
/// audio stops the moment the user taps.
func pause() {
guard isReading, !isPaused else { return }
synthesizer.pauseSpeaking(at: .immediate)
isPaused = true
}
func resume() {
guard isReading, isPaused else { return }
synthesizer.continueSpeaking()
isPaused = false
}
func stop() {
synthesizer.stopSpeaking(at: .immediate)
queue.removeAll()
queueCursor = 0
isReading = false
isPaused = false
currentParagraphIndex = nil
currentWordIndex = nil
deactivateAudioSession()
}
// MARK: - Vocab detection + word ranges
/// Vocabulary entries in the book are formatted `palabra = meaning`.
/// Reading them aloud says "palabra equals meaning" which is awkward, and
/// they're reference material, so the read-along skips them.
static func isVocabLine(_ paragraph: String) -> Bool {
paragraph.contains(" = ")
}
/// Word ranges that match the BookReaderView's space-split rendering
/// the visible word index N in a paragraph corresponds to wordRanges[N].
static func wordRanges(in text: String) -> [Range<String.Index>] {
var ranges: [Range<String.Index>] = []
var i = text.startIndex
while i < text.endIndex {
while i < text.endIndex && text[i] == " " {
i = text.index(after: i)
}
guard i < text.endIndex else { break }
let start = i
while i < text.endIndex && text[i] != " " {
i = text.index(after: i)
}
ranges.append(start..<i)
}
return ranges
}
// MARK: - Private
private func speakCurrent() {
guard queueCursor < queue.count else {
stop()
return
}
let entry = queue[queueCursor]
currentParagraphIndex = entry.paragraphIndex
currentWordIndex = nil
let utterance = AVSpeechUtterance(string: entry.text)
utterance.voice = resolveVoice()
utterance.rate = rate
utterance.pitchMultiplier = 1.0
utterance.postUtteranceDelay = 0.20
synthesizer.speak(utterance)
}
private func resolveVoice() -> AVSpeechSynthesisVoice? {
if let id = voiceIdentifier, let v = AVSpeechSynthesisVoice(identifier: id) {
return v
}
return AVSpeechSynthesisVoice(language: "es-ES")
}
private func configureAudioSession() {
guard !audioSessionConfigured else { return }
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .spokenAudio, options: [])
try session.setActive(true)
audioSessionConfigured = true
} catch {
print("[BookSpeech] audio session failed: \(error)")
}
}
/// Release audio focus on stop so the OS hands control back to whatever
/// app was playing before (music, podcast, etc.). Without this the
/// session stays "active" until the app is killed.
private func deactivateAudioSession() {
guard audioSessionConfigured else { return }
do {
try AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
} catch {
print("[BookSpeech] audio session deactivation failed: \(error)")
}
audioSessionConfigured = false
}
private func handleWillSpeakRange(_ range: NSRange) {
guard queueCursor < queue.count else { return }
let entry = queue[queueCursor]
guard let stringRange = Range(range, in: entry.text) else { return }
let lower = stringRange.lowerBound
let idx = entry.wordRanges.firstIndex {
$0.lowerBound <= lower && lower < $0.upperBound
}
if let idx, idx != currentWordIndex {
currentWordIndex = idx
}
}
private func handleDidFinish() {
queueCursor += 1
if queueCursor < queue.count {
speakCurrent()
} else {
stop()
}
}
// MARK: - AVSpeechSynthesizerDelegate
nonisolated func speechSynthesizer(
_ synthesizer: AVSpeechSynthesizer,
willSpeakRangeOfSpeechString characterRange: NSRange,
utterance: AVSpeechUtterance
) {
Task { @MainActor in self.handleWillSpeakRange(characterRange) }
}
nonisolated func speechSynthesizer(
_ synthesizer: AVSpeechSynthesizer,
didFinish utterance: AVSpeechUtterance
) {
Task { @MainActor in self.handleDidFinish() }
}
}
+437 -3
View File
@@ -3,9 +3,15 @@ import SharedModels
import Foundation import Foundation
actor DataLoader { actor DataLoader {
static let courseDataVersion = 6 static let courseDataVersion = 9 // bump: all 19 tense guides + 36 grammar notes enriched to teacher-handout depth
static let courseDataKey = "courseDataVersion" static let courseDataKey = "courseDataVersion"
static let textbookDataVersion = 14
static let textbookDataKey = "textbookDataVersion"
static let bookDataVersion = 6 // bump: BookChapter.paragraphCount added
static let bookDataKey = "bookDataVersion"
/// Quick check: does the DB need seeding or course data refresh? /// Quick check: does the DB need seeding or course data refresh?
static func needsSeeding(container: ModelContainer) async -> Bool { static func needsSeeding(container: ModelContainer) async -> Bool {
let context = ModelContext(container) let context = ModelContext(container)
@@ -15,6 +21,12 @@ actor DataLoader {
let storedVersion = UserDefaults.standard.integer(forKey: courseDataKey) let storedVersion = UserDefaults.standard.integer(forKey: courseDataKey)
if storedVersion < courseDataVersion { return true } if storedVersion < courseDataVersion { return true }
let textbookVersion = UserDefaults.standard.integer(forKey: textbookDataKey)
if textbookVersion < textbookDataVersion { return true }
let bookVersion = UserDefaults.standard.integer(forKey: bookDataKey)
if bookVersion < bookDataVersion { return true }
return false return false
} }
@@ -133,6 +145,98 @@ actor DataLoader {
// Seed course data (uses the same mainContext so @Query sees it) // Seed course data (uses the same mainContext so @Query sees it)
seedCourseData(context: context) seedCourseData(context: context)
// Seed textbook data only bump the version key if the seed
// actually inserted rows, so a missing/unparseable bundle doesn't
// permanently lock us out of future re-seeds.
if seedTextbookData(context: context) {
UserDefaults.standard.set(textbookDataVersion, forKey: textbookDataKey)
}
if seedBooks(context: context) {
UserDefaults.standard.set(bookDataVersion, forKey: bookDataKey)
}
}
/// Re-seed books if the version has changed or the rows are missing.
static func refreshBooksDataIfNeeded(container: ModelContainer) async {
let shared = UserDefaults.standard
let context = ModelContext(container)
let existingCount = (try? context.fetchCount(FetchDescriptor<Book>())) ?? 0
let storedVersion = shared.integer(forKey: bookDataKey)
let versionCurrent = storedVersion >= bookDataVersion
print("[DataLoader] refreshBooksDataIfNeeded: existing=\(existingCount) stored=\(storedVersion) target=\(bookDataVersion) versionCurrent=\(versionCurrent)")
if versionCurrent && existingCount > 0 { return }
if let existing = try? context.fetch(FetchDescriptor<Book>()) {
for book in existing { context.delete(book) }
}
if let existing = try? context.fetch(FetchDescriptor<BookChapter>()) {
for chapter in existing { context.delete(chapter) }
}
do {
try context.save()
} catch {
print("[DataLoader] ERROR: book wipe save failed: \(error)")
return
}
if seedBooks(context: context) {
shared.set(bookDataVersion, forKey: bookDataKey)
print("[DataLoader] Book data re-seeded to version \(bookDataVersion)")
} else {
print("[DataLoader] Book reseed produced no rows — leaving version key untouched")
}
}
/// Re-seed textbook data if the version has changed OR if the rows are
/// missing on disk. The row-count check exists because anything opening
/// this store with a subset schema (e.g. an out-of-date widget extension)
/// can destructively drop the rows without touching UserDefaults so a
/// pure version-flag trigger would leave us permanently empty.
static func refreshTextbookDataIfNeeded(container: ModelContainer) async {
let shared = UserDefaults.standard
let context = ModelContext(container)
let existingCount = (try? context.fetchCount(FetchDescriptor<TextbookChapter>())) ?? 0
let versionCurrent = shared.integer(forKey: textbookDataKey) >= textbookDataVersion
if versionCurrent && existingCount > 0 { return }
if versionCurrent {
print("Textbook data version current but store has \(existingCount) chapters — re-seeding...")
} else {
print("Textbook data version outdated — re-seeding...")
}
// Fetch + delete individually instead of batch delete. SwiftData's
// context.delete(model:) hits the store directly and doesn't always
// clear the unique-constraint index before the reseed's save runs,
// so re-inserting rows with the same .unique id can throw.
let textbookCourseName = "Complete Spanish Step-by-Step"
if let existing = try? context.fetch(FetchDescriptor<TextbookChapter>()) {
for chapter in existing { context.delete(chapter) }
}
let deckDescriptor = FetchDescriptor<CourseDeck>(
predicate: #Predicate<CourseDeck> { $0.courseName == textbookCourseName }
)
if let decks = try? context.fetch(deckDescriptor) {
for deck in decks { context.delete(deck) }
}
do {
try context.save()
} catch {
print("[DataLoader] ERROR: textbook wipe save failed: \(error)")
return
}
if seedTextbookData(context: context) {
shared.set(textbookDataVersion, forKey: textbookDataKey)
print("Textbook data re-seeded to version \(textbookDataVersion)")
} else {
print("Textbook re-seed failed — leaving version key untouched so next launch retries")
}
} }
/// Re-seed course data if the version has changed (e.g. examples were added). /// Re-seed course data if the version has changed (e.g. examples were added).
@@ -145,14 +249,35 @@ actor DataLoader {
print("Course data version outdated — re-seeding...") print("Course data version outdated — re-seeding...")
let context = ModelContext(container) let context = ModelContext(container)
// Delete existing course data // Delete existing course data + tense guides so they can be re-seeded
// with updated bodies from the bundled conjuga_data.json.
try? context.delete(model: VocabCard.self) try? context.delete(model: VocabCard.self)
try? context.delete(model: CourseDeck.self) try? context.delete(model: CourseDeck.self)
try? context.delete(model: TenseGuide.self)
try? context.save() try? context.save()
// Re-seed // Re-seed tense guides from the bundled JSON
if let url = Bundle.main.url(forResource: "conjuga_data", withExtension: "json"),
let data = try? Data(contentsOf: url),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let guides = json["tenseGuides"] as? [[String: Any]] {
for g in guides {
guard let tenseId = g["tenseId"] as? String,
let title = g["title"] as? String,
let body = g["body"] as? String else { continue }
context.insert(TenseGuide(tenseId: tenseId, title: title, body: body))
}
try? context.save()
print("Re-seeded \(guides.count) tense guides")
}
// Re-seed course data
seedCourseData(context: context) seedCourseData(context: context)
// Textbook's vocab decks/cards share the same CourseDeck/VocabCard
// entities, so they were just wiped above. Reseed them.
seedTextbookVocabDecks(context: context, courseName: "Complete Spanish Step-by-Step")
shared.set(courseDataVersion, forKey: courseDataKey) shared.set(courseDataVersion, forKey: courseDataKey)
print("Course data re-seeded to version \(courseDataVersion)") print("Course data re-seeded to version \(courseDataVersion)")
} }
@@ -319,4 +444,313 @@ actor DataLoader {
context.insert(reviewCard) context.insert(reviewCard)
return reviewCard return reviewCard
} }
// MARK: - Textbook seeding
@discardableResult
private static func seedTextbookData(context: ModelContext) -> Bool {
let url = Bundle.main.url(forResource: "textbook_data", withExtension: "json")
?? Bundle.main.bundleURL.appendingPathComponent("textbook_data.json")
guard let data = try? Data(contentsOf: url) else {
print("[DataLoader] textbook_data.json not bundled — skipping textbook seed")
return false
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("[DataLoader] ERROR: Could not parse textbook_data.json")
return false
}
let courseName = (json["courseName"] as? String) ?? "Textbook"
guard let chapters = json["chapters"] as? [[String: Any]] else {
print("[DataLoader] ERROR: textbook_data.json missing chapters")
return false
}
var inserted = 0
for ch in chapters {
guard let id = ch["id"] as? String,
let number = ch["number"] as? Int,
let title = ch["title"] as? String,
let blocksRaw = ch["blocks"] as? [[String: Any]] else { continue }
let part = (ch["part"] as? Int) ?? 0
// Normalize each block to canonical keys expected by TextbookBlock decoder.
var normalized: [[String: Any]] = []
var exerciseCount = 0
var vocabTableCount = 0
for (i, b) in blocksRaw.enumerated() {
var out: [String: Any] = [:]
out["index"] = i
let kind = (b["kind"] as? String) ?? ""
out["kind"] = kind
switch kind {
case "heading":
if let level = b["level"] { out["level"] = level }
if let text = b["text"] { out["text"] = text }
case "paragraph":
if let text = b["text"] { out["text"] = text }
case "key_vocab_header":
break
case "vocab_table":
vocabTableCount += 1
if let src = b["sourceImage"] { out["sourceImage"] = src }
if let lines = b["ocrLines"] { out["ocrLines"] = lines }
if let conf = b["ocrConfidence"] { out["ocrConfidence"] = conf }
// Paired SpanishEnglish cards from the bounding-box extractor.
if let cards = b["cards"] as? [[String: Any]], !cards.isEmpty {
let normalized: [[String: Any]] = cards.compactMap { c in
guard let front = c["front"] as? String,
let back = c["back"] as? String else { return nil }
return ["front": front, "back": back]
}
if !normalized.isEmpty {
out["cards"] = normalized
}
}
case "exercise":
exerciseCount += 1
if let exId = b["id"] { out["exerciseId"] = exId }
if let inst = b["instruction"] { out["instruction"] = inst }
if let extra = b["extra"] { out["extra"] = extra }
if let prompts = b["prompts"] { out["prompts"] = prompts }
if let items = b["answerItems"] { out["answerItems"] = items }
if let freeform = b["freeform"] { out["freeform"] = freeform }
default:
break
}
normalized.append(out)
}
let bodyJSON: Data
do {
bodyJSON = try JSONSerialization.data(withJSONObject: normalized, options: [])
} catch {
print("[DataLoader] failed to encode chapter \(number) blocks: \(error)")
continue
}
let chapter = TextbookChapter(
id: id,
number: number,
title: title,
part: part,
courseName: courseName,
bodyJSON: bodyJSON,
exerciseCount: exerciseCount,
vocabTableCount: vocabTableCount
)
context.insert(chapter)
inserted += 1
}
do {
try context.save()
} catch {
print("[DataLoader] ERROR: textbook chapter save failed: \(error)")
return false
}
// Verify rows actually hit the store guards against the case where
// save returned cleanly but no rows were persisted.
let persisted = (try? context.fetchCount(FetchDescriptor<TextbookChapter>())) ?? 0
guard persisted > 0 else {
print("[DataLoader] ERROR: textbook seeded \(inserted) chapters but persisted count is 0")
return false
}
// Seed textbook-derived vocabulary flashcards as CourseDecks so the
// existing Course UI can surface them alongside LanGo decks.
seedTextbookVocabDecks(context: context, courseName: courseName)
print("Textbook seeding complete: \(inserted) chapters inserted, \(persisted) persisted")
return true
}
// MARK: - Books seeding
/// Walk the bundle for any `book_*.json` resources and seed `Book` +
/// `BookChapter` rows from each one. Returns true when at least one row
/// was inserted (mirrors `seedTextbookData`'s contract).
@discardableResult
private static func seedBooks(context: ModelContext) -> Bool {
let bookURLs = bundledBookJSONURLs()
guard !bookURLs.isEmpty else {
print("[DataLoader] no book_*.json bundled — skipping book seed")
return false
}
var insertedBooks = 0
for url in bookURLs {
guard let data = try? Data(contentsOf: url),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("[DataLoader] WARN: could not read \(url.lastPathComponent)")
continue
}
guard let slug = json["slug"] as? String,
let title = json["title"] as? String,
let chaptersRaw = json["chapters"] as? [[String: Any]] else {
print("[DataLoader] WARN: \(url.lastPathComponent) missing required fields")
continue
}
let author = (json["author"] as? String) ?? ""
let language = (json["language"] as? String) ?? "es"
// Pre-computed per-book glossary, keyed by cleaned word.
var glossary: [String: WordGloss] = [:]
if let glossaryRaw = json["glossary"] as? [String: [String: String]] {
for (word, fields) in glossaryRaw {
glossary[word] = WordGloss(
baseForm: fields["baseForm"] ?? word,
english: fields["english"] ?? "",
partOfSpeech: fields["partOfSpeech"] ?? ""
)
}
}
let glossaryData = (try? JSONEncoder().encode(glossary)) ?? Data()
let book = Book(
slug: slug,
title: title,
author: author,
language: language,
chapterCount: chaptersRaw.count,
accentColorHex: accentHex(forSlug: slug),
glossaryJSON: glossaryData
)
context.insert(book)
insertedBooks += 1
for ch in chaptersRaw {
guard let number = ch["number"] as? Int,
let chTitle = ch["title"] as? String else { continue }
let paragraphsES = (ch["paragraphsES"] as? [String]) ?? []
let paragraphsEN = (ch["paragraphsEN"] as? [String]) ?? []
let esData = (try? JSONEncoder().encode(paragraphsES)) ?? Data()
let enData = (try? JSONEncoder().encode(paragraphsEN)) ?? Data()
let chapter = BookChapter(
id: "\(slug)-ch\(number)",
bookSlug: slug,
number: number,
title: chTitle,
paragraphCount: paragraphsES.count,
paragraphsESJSON: esData,
paragraphsENJSON: enData
)
context.insert(chapter)
}
}
do {
try context.save()
} catch {
print("[DataLoader] ERROR: book save failed: \(error)")
return false
}
let persistedBooks = (try? context.fetchCount(FetchDescriptor<Book>())) ?? 0
let persistedChapters = (try? context.fetchCount(FetchDescriptor<BookChapter>())) ?? 0
guard persistedBooks > 0 else {
print("[DataLoader] ERROR: seeded \(insertedBooks) books but persisted count is 0")
return false
}
print("Book seeding complete: \(persistedBooks) books, \(persistedChapters) chapters")
return true
}
/// Slugs of books bundled with the app. Kept explicit so device installs
/// don't depend on `Bundle.urls(forResourcesWithExtension:subdirectory:)`
/// successfully enumerating the bundle that API has been observed to
/// return empty for some iOS configurations even when the resource is
/// present, matching the same `bundleURL.appendingPathComponent` fallback
/// used by the textbook seed.
private static let bundledBookSlugs: [String] = [
"olly-vol2",
]
/// Resolve URLs for every bundled book. Uses the explicit-slug fast path
/// first (mirrors `seedTextbookData`'s lookup pattern), then falls back to
/// directory enumeration so newly-bundled books are picked up without a
/// code change.
private static func bundledBookJSONURLs() -> [URL] {
var seen = Set<String>()
var out: [URL] = []
let bundle = Bundle.main
for slug in bundledBookSlugs {
let filename = "book_\(slug).json"
let url = bundle.url(forResource: "book_\(slug)", withExtension: "json")
?? bundle.bundleURL.appendingPathComponent(filename)
if FileManager.default.fileExists(atPath: url.path),
seen.insert(filename).inserted {
out.append(url)
}
}
if let urls = bundle.urls(forResourcesWithExtension: "json", subdirectory: nil) {
for url in urls where url.lastPathComponent.hasPrefix("book_") {
if seen.insert(url.lastPathComponent).inserted {
out.append(url)
}
}
}
let names = out.map(\.lastPathComponent).joined(separator: ", ")
print("[DataLoader] bundledBookJSONURLs found \(out.count) files: [\(names)]")
return out.sorted { $0.lastPathComponent < $1.lastPathComponent }
}
/// Deterministic accent colour for a book, derived from its slug so the
/// cover tile has a stable colour across launches.
private static func accentHex(forSlug slug: String) -> String {
let palette = [
"#7B6CF6", "#E07A5F", "#3D5A80", "#81B29A",
"#F2CC8F", "#D4A5A5", "#5B8A72", "#A06CD5",
]
let hash = slug.unicodeScalars.reduce(0) { ($0 &* 31) &+ Int($1.value) }
return palette[abs(hash) % palette.count]
}
private static func seedTextbookVocabDecks(context: ModelContext, courseName: String) {
let url = Bundle.main.url(forResource: "textbook_vocab", withExtension: "json")
?? Bundle.main.bundleURL.appendingPathComponent("textbook_vocab.json")
guard let data = try? Data(contentsOf: url),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let chaptersArr = json["chapters"] as? [[String: Any]]
else { return }
let courseSlug = courseName.lowercased()
.replacingOccurrences(of: " ", with: "-")
var deckCount = 0
var cardCount = 0
for chData in chaptersArr {
guard let chNum = chData["chapter"] as? Int,
let cards = chData["cards"] as? [[String: Any]],
!cards.isEmpty else { continue }
let deckId = "textbook_\(courseSlug)_ch\(chNum)"
let title = "Chapter \(chNum) vocabulary"
let deck = CourseDeck(
id: deckId,
weekNumber: chNum,
title: title,
cardCount: cards.count,
courseName: courseName,
isReversed: false
)
context.insert(deck)
deckCount += 1
for c in cards {
guard let front = c["front"] as? String,
let back = c["back"] as? String else { continue }
let card = VocabCard(front: front, back: back, deckId: deckId)
card.deck = deck
context.insert(card)
cardCount += 1
}
}
try? context.save()
print("Textbook vocab seeding complete: \(deckCount) decks, \(cardCount) cards")
}
} }
@@ -0,0 +1,88 @@
import Foundation
import SharedModels
import SwiftData
/// Cloud-context CRUD for `ExtraStudyMark`. Uniqueness is enforced in code via
/// fetch-or-create on `id` (CloudKit forbids `@Attribute(.unique)`).
struct ExtraStudyStore {
let context: ModelContext
private func fetchMark(id: String) -> ExtraStudyMark? {
let descriptor = FetchDescriptor<ExtraStudyMark>(
predicate: #Predicate<ExtraStudyMark> { $0.id == id }
)
return (try? context.fetch(descriptor))?.first
}
func contains(card: VocabCard) -> Bool {
fetchMark(id: CourseCardStore.reviewKey(for: card)) != nil
}
/// Toggle a mark for the given card. Returns the new "is marked" state.
@discardableResult
func toggle(
card: VocabCard,
courseName: String,
weekNumber: Int
) -> Bool {
let id = CourseCardStore.reviewKey(for: card)
if let existing = fetchMark(id: id) {
context.delete(existing)
try? context.save()
return false
}
let mark = ExtraStudyMark(
id: id,
deckId: card.deckId,
courseName: courseName,
weekNumber: weekNumber,
front: card.front,
back: card.back
)
context.insert(mark)
try? context.save()
return true
}
func count(courseName: String, weekNumber: Int) -> Int {
let descriptor = FetchDescriptor<ExtraStudyMark>(
predicate: #Predicate<ExtraStudyMark> {
$0.courseName == courseName && $0.weekNumber == weekNumber
}
)
return (try? context.fetchCount(descriptor)) ?? 0
}
func countsByWeek(courseName: String) -> [Int: Int] {
let descriptor = FetchDescriptor<ExtraStudyMark>(
predicate: #Predicate<ExtraStudyMark> { $0.courseName == courseName }
)
let marks = (try? context.fetch(descriptor)) ?? []
var counts: [Int: Int] = [:]
for mark in marks {
counts[mark.weekNumber, default: 0] += 1
}
return counts
}
func fetch(courseName: String, weekNumber: Int) -> [ExtraStudyMark] {
let descriptor = FetchDescriptor<ExtraStudyMark>(
predicate: #Predicate<ExtraStudyMark> {
$0.courseName == courseName && $0.weekNumber == weekNumber
},
sortBy: [SortDescriptor(\.markedAt)]
)
return (try? context.fetch(descriptor)) ?? []
}
func fetchIds(courseName: String, weekNumber: Int) -> Set<String> {
Set(fetch(courseName: courseName, weekNumber: weekNumber).map(\.id))
}
}
/// Lightweight context passed to `VocabFlashcardView` so the in-session star
/// button knows which week/course to attribute a mark to.
struct ExtraStudyMarkContext: Equatable {
let courseName: String
let weekNumber: Int
}
@@ -4,14 +4,23 @@ import SwiftData
struct PracticeSettings: Sendable { struct PracticeSettings: Sendable {
let selectedLevel: String let selectedLevel: String
let selectedLevels: Set<String>
let enabledTenses: Set<String> let enabledTenses: Set<String>
let enabledIrregularCategories: Set<IrregularSpan.SpanCategory>
let showVosotros: Bool let showVosotros: Bool
let showReflexiveVerbsOnly: Bool
let reflexiveBaseInfinitives: Set<String>
init(progress: UserProgress?) { init(progress: UserProgress?, reflexiveBaseInfinitives: Set<String> = []) {
let resolved = progress?.enabledTenseIDs ?? [] let resolvedTenses = progress?.enabledTenseIDs ?? []
let resolvedLevels = progress?.selectedVerbLevels ?? []
self.selectedLevel = progress?.selectedLevel ?? VerbLevel.basic.rawValue self.selectedLevel = progress?.selectedLevel ?? VerbLevel.basic.rawValue
self.enabledTenses = Set(resolved) self.selectedLevels = Set(resolvedLevels.map(\.rawValue))
self.enabledTenses = Set(resolvedTenses)
self.enabledIrregularCategories = progress?.enabledIrregularCategories ?? []
self.showVosotros = progress?.showVosotros ?? true self.showVosotros = progress?.showVosotros ?? true
self.showReflexiveVerbsOnly = progress?.showReflexiveVerbsOnly ?? false
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
} }
var selectionTenseIDs: [String] { var selectionTenseIDs: [String] {
@@ -36,16 +45,25 @@ struct FullTablePrompt {
struct PracticeSessionService { struct PracticeSessionService {
let localContext: ModelContext let localContext: ModelContext
let cloudContext: ModelContext let cloudContext: ModelContext
let reflexiveBaseInfinitives: Set<String>
private let referenceStore: ReferenceStore private let referenceStore: ReferenceStore
init(localContext: ModelContext, cloudContext: ModelContext) { init(
localContext: ModelContext,
cloudContext: ModelContext,
reflexiveBaseInfinitives: Set<String> = []
) {
self.localContext = localContext self.localContext = localContext
self.cloudContext = cloudContext self.cloudContext = cloudContext
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
self.referenceStore = ReferenceStore(context: localContext) self.referenceStore = ReferenceStore(context: localContext)
} }
func settings() -> PracticeSettings { func settings() -> PracticeSettings {
PracticeSettings(progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext)) PracticeSettings(
progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext),
reflexiveBaseInfinitives: reflexiveBaseInfinitives
)
} }
func nextCard(for focusMode: FocusMode, excludingVerbId lastVerbId: Int? = nil) -> PracticeCardLoad? { func nextCard(for focusMode: FocusMode, excludingVerbId lastVerbId: Int? = nil) -> PracticeCardLoad? {
@@ -77,24 +95,114 @@ struct PracticeSessionService {
return nil return nil
} }
func randomFullTablePrompt() -> FullTablePrompt? { /// Builds a Full Table prompt. `previousTenseId` / `previousEnding` describe
/// the prompt just shown; when possible the next prompt avoids repeating the
/// same tense back-to-back and switches the verb's ending family
/// (-ar -er/-ir) so consecutive rounds feel varied. Both are best-effort:
/// if honouring them would leave no eligible combo, the constraint is
/// dropped rather than dead-ending.
func randomFullTablePrompt(
previousTenseId: String? = nil,
previousEnding: String? = nil
) -> FullTablePrompt? {
let settings = settings() let settings = settings()
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel) // Full Table is testing the user's grasp of regular conjugation patterns,
// not vocabulary recognition. Level filter is intentionally bypassed so
// we draw from the entire verb pool being able to conjugate `hablar`
// regularly transfers to any other regular verb regardless of "level".
// Irregular-category and tense filters still apply via downstream checks.
let verbs = applyReflexiveFilter(
to: referenceStore.fetchVerbs(),
settings: settings
)
guard !verbs.isEmpty else { return nil } guard !verbs.isEmpty else { return nil }
for _ in 0..<40 { let candidateTenseIds = settings.selectionTenseIDs
guard let verb = verbs.randomElement(), guard !candidateTenseIds.isEmpty else { return nil }
let tenseId = settings.selectionTenseIDs.randomElement(),
let tenseInfo = TenseInfo.find(tenseId) else { continue }
let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId) let tenseChoices = tenseChoicesAvoidingRepeat(candidateTenseIds, previous: previousTenseId)
if forms.isEmpty { continue } let verbChoices = verbChoicesSwitchingFamily(verbs, previousEnding: previousEnding)
return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: forms) let isConstrained = tenseChoices.count != candidateTenseIds.count
|| verbChoices.count != verbs.count
// Best-effort: random-sample the constrained pool first so consecutive
// prompts vary the tense and the -ar/-er-ir family.
if isConstrained,
let prompt = sampleFullTablePrompt(verbs: verbChoices, tenseIds: tenseChoices) {
return prompt
}
// No constraint, or honouring it found nothing quickly sample the
// full pool.
if let prompt = sampleFullTablePrompt(verbs: verbs, tenseIds: candidateTenseIds) {
return prompt
}
// Guarantee: if any eligible (verb, tense) combo exists in the data we
// return one. Only return nil when the user's settings genuinely produce
// an empty pool (so the UI can show an error state instead of a blank).
for verb in verbs.shuffled() {
for tenseId in candidateTenseIds.shuffled() {
guard let tenseInfo = TenseInfo.find(tenseId) else { continue }
if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) {
return prompt
}
}
} }
return nil return nil
} }
/// Random-sample up to 40 (verb, tense) pairs for a fully-regular combo.
/// With ~1750 verbs and several hundred eligible combos this almost always
/// succeeds within a handful of attempts.
private func sampleFullTablePrompt(verbs: [Verb], tenseIds: [String]) -> FullTablePrompt? {
guard !verbs.isEmpty, !tenseIds.isEmpty else { return nil }
for _ in 0..<40 {
guard let verb = verbs.randomElement(),
let tenseId = tenseIds.randomElement(),
let tenseInfo = TenseInfo.find(tenseId) else { continue }
if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) {
return prompt
}
}
return nil
}
/// Tense ids with the previous one removed only when more than one tense
/// is selected and removing it still leaves a choice.
private func tenseChoicesAvoidingRepeat(_ ids: [String], previous: String?) -> [String] {
guard ids.count > 1, let previous else { return ids }
let filtered = ids.filter { $0 != previous }
return filtered.isEmpty ? ids : filtered
}
/// Verbs whose ending family (-ar vs -er/-ir) differs from the previous
/// verb's only when both families are actually present in the pool.
private func verbChoicesSwitchingFamily(_ verbs: [Verb], previousEnding: String?) -> [Verb] {
guard let previousEnding else { return verbs }
let previousIsAr = (previousEnding == "ar")
let switched = verbs.filter { ($0.ending == "ar") != previousIsAr }
return switched.isEmpty ? verbs : switched
}
/// Returns a `FullTablePrompt` if this verb's forms in the given tense
/// follow the regular pattern (per `FullTableEligibility`). Nil otherwise.
private func makePromptIfFullyRegular(
verb: Verb,
tenseId: String,
tenseInfo: TenseInfo
) -> FullTablePrompt? {
let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId)
guard !forms.isEmpty else { return nil }
// Forms must arrive in personIndex order so the regularity array lines
// up. `fetchForms` already sorts them, but assert for safety.
let sorted = forms.sorted { $0.personIndex < $1.personIndex }
let regularities = sorted.map { $0.regularity }
guard FullTableEligibility.isFullyRegular(regularities: regularities) else { return nil }
return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: sorted)
}
func rate(verbId: Int, tenseId: String, personIndex: Int, quality: ReviewQuality) -> [Badge] { func rate(verbId: Int, tenseId: String, personIndex: Int, quality: ReviewQuality) -> [Badge] {
ReviewStore.recordReview( ReviewStore.recordReview(
verbId: verbId, verbId: verbId,
@@ -131,6 +239,27 @@ struct PracticeSessionService {
return buildCardLoad(verb: verb, form: form) return buildCardLoad(verb: verb, form: form)
} }
/// When the user has "Reflexive verbs only" enabled, restrict the allowed
/// verb-id set to IDs whose infinitive is in the curated list.
/// No-op otherwise.
private func applyReflexiveFilter(to ids: Set<Int>, settings: PracticeSettings) -> Set<Int> {
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
return ids
}
let matching = ids.filter { id in
guard let verb = referenceStore.fetchVerb(id: id) else { return false }
return settings.reflexiveBaseInfinitives.contains(verb.infinitive.lowercased())
}
return matching
}
private func applyReflexiveFilter(to verbs: [Verb], settings: PracticeSettings) -> [Verb] {
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
return verbs
}
return verbs.filter { settings.reflexiveBaseInfinitives.contains($0.infinitive.lowercased()) }
}
private func buildCardLoad(verb: Verb, form: VerbForm) -> PracticeCardLoad { private func buildCardLoad(verb: Verb, form: VerbForm) -> PracticeCardLoad {
let spans = referenceStore.fetchSpans( let spans = referenceStore.fetchSpans(
verbId: form.verbId, verbId: form.verbId,
@@ -152,7 +281,13 @@ struct PracticeSessionService {
private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? { private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? {
let settings = settings() let settings = settings()
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel) let allowedVerbIds = applyReflexiveFilter(
to: referenceStore.allowedVerbIDs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
),
settings: settings
)
let now = Date() let now = Date()
var descriptor = FetchDescriptor<ReviewCard>( var descriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.dueDate <= now }, predicate: #Predicate<ReviewCard> { $0.dueDate <= now },
@@ -179,7 +314,13 @@ struct PracticeSessionService {
private func pickWeakForm() -> VerbForm? { private func pickWeakForm() -> VerbForm? {
let settings = settings() let settings = settings()
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel) let allowedVerbIds = applyReflexiveFilter(
to: referenceStore.allowedVerbIDs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
),
settings: settings
)
let descriptor = FetchDescriptor<ReviewCard>( let descriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 }, predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 },
@@ -201,7 +342,15 @@ struct PracticeSessionService {
private func pickIrregularForm(filter: IrregularityFilter) -> VerbForm? { private func pickIrregularForm(filter: IrregularityFilter) -> VerbForm? {
let settings = settings() let settings = settings()
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel) // Focus mode explicitly selects one irregular category, so the user's
// settings-level irregular filter is deliberately skipped here.
let allowedVerbIds = applyReflexiveFilter(
to: referenceStore.allowedVerbIDs(
selectedLevels: settings.selectedLevels,
irregularCategories: []
),
settings: settings
)
let typeRange: ClosedRange<Int> let typeRange: ClosedRange<Int>
switch filter { switch filter {
@@ -238,7 +387,13 @@ struct PracticeSessionService {
private func pickCommonTenseForm() -> VerbForm? { private func pickCommonTenseForm() -> VerbForm? {
let settings = settings() let settings = settings()
let coreTenseIDs = TenseID.coreTenseIDs let coreTenseIDs = TenseID.coreTenseIDs
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel) let verbs = applyReflexiveFilter(
to: referenceStore.fetchVerbs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
),
settings: settings
)
guard let verb = verbs.randomElement() else { return nil } guard let verb = verbs.randomElement() else { return nil }
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
@@ -251,7 +406,13 @@ struct PracticeSessionService {
private func pickRandomForm() -> VerbForm? { private func pickRandomForm() -> VerbForm? {
let settings = settings() let settings = settings()
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel) let verbs = applyReflexiveFilter(
to: referenceStore.fetchVerbs(
selectedLevels: settings.selectedLevels,
irregularCategories: settings.enabledIrregularCategories
),
settings: settings
)
guard let verb = verbs.randomElement() else { return nil } guard let verb = verbs.randomElement() else { return nil }
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
@@ -65,28 +65,26 @@ final class PronunciationService {
do { do {
let audioSession = AVAudioSession.sharedInstance() let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playAndRecord, mode: .measurement, options: [.duckOthers, .defaultToSpeaker]) try audioSession.setCategory(.record, mode: .measurement, options: [.duckOthers])
try audioSession.setActive(true, options: .notifyOthersOnDeactivation) try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
audioEngine = AVAudioEngine() // Use SFSpeechAudioBufferRecognitionRequest with the recognizer
// directly avoid AVAudioEngine entirely since it produces
// zero-length buffers on some devices causing assertion crashes.
request = SFSpeechAudioBufferRecognitionRequest() request = SFSpeechAudioBufferRecognitionRequest()
guard let request else { return }
guard let audioEngine, let request else { return }
request.shouldReportPartialResults = true request.shouldReportPartialResults = true
request.requiresOnDeviceRecognition = recognizer.supportsOnDeviceRecognition
// Use AVAudioEngine with the native input format
audioEngine = AVAudioEngine()
guard let audioEngine else { return }
let inputNode = audioEngine.inputNode let inputNode = audioEngine.inputNode
let recordingFormat = inputNode.outputFormat(forBus: 0)
// Validate format 0 channels crashes installTap // Use nil format lets the system pick a compatible format
guard recordingFormat.channelCount > 0 else { // and avoids the mDataByteSize(0) assertion from format mismatches
print("[PronunciationService] invalid recording format (0 channels)") inputNode.installTap(onBus: 0, bufferSize: 4096, format: nil) { buffer, _ in
self.audioEngine = nil
self.request = nil
return
}
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in
guard buffer.frameLength > 0 else { return }
request.append(buffer) request.append(buffer)
} }
@@ -27,6 +27,50 @@ struct ReferenceStore {
Set(fetchVerbs(selectedLevel: selectedLevel).map(\.id)) Set(fetchVerbs(selectedLevel: selectedLevel).map(\.id))
} }
/// Union of data-levels for all selected user-facing levels.
/// Empty input produces an empty result callers decide how to handle that.
func fetchVerbs(selectedLevels: Set<String>) -> [Verb] {
guard !selectedLevels.isEmpty else { return [] }
let ids = PracticeFilter.verbIDs(
matchingLevels: selectedLevels,
in: fetchVerbs().map { .init(id: $0.id, level: $0.level) }
)
return fetchVerbs().filter { ids.contains($0.id) }
}
/// Practice verb pool intersecting selected levels with selected irregular-span categories.
/// Delegates to `PracticeFilter` so the intersection logic is unit-tested
/// in SharedModels without a ModelContainer (Issue #26).
func allowedVerbIDs(
selectedLevels: Set<String>,
irregularCategories: Set<IrregularSpan.SpanCategory>
) -> Set<Int> {
PracticeFilter.allowedVerbIDs(
verbs: fetchVerbs().map { .init(id: $0.id, level: $0.level) },
spans: allIrregularSlots(),
selectedLevels: selectedLevels,
irregularCategories: irregularCategories
)
}
/// Convenience: full Verb objects passing both filters.
func fetchVerbs(
selectedLevels: Set<String>,
irregularCategories: Set<IrregularSpan.SpanCategory>
) -> [Verb] {
let ids = allowedVerbIDs(
selectedLevels: selectedLevels,
irregularCategories: irregularCategories
)
return fetchVerbs().filter { ids.contains($0.id) }
}
private func allIrregularSlots() -> [PracticeFilter.IrregularSlot] {
let descriptor = FetchDescriptor<IrregularSpan>()
let spans = (try? context.fetch(descriptor)) ?? []
return spans.map { .init(verbId: $0.verbId, category: $0.category) }
}
func fetchVerb(id: Int) -> Verb? { func fetchVerb(id: Int) -> Verb? {
let descriptor = FetchDescriptor<Verb>(predicate: #Predicate<Verb> { $0.id == id }) let descriptor = FetchDescriptor<Verb>(predicate: #Predicate<Verb> { $0.id == id })
return (try? context.fetch(descriptor))?.first return (try? context.fetch(descriptor))?.first
@@ -50,6 +94,19 @@ struct ReferenceStore {
return (try? context.fetch(descriptor)) ?? [] return (try? context.fetch(descriptor)) ?? []
} }
/// Map of tenseId conjugated forms for a verb, used to ground and
/// validate LLM-generated example sentences.
func conjugatedForms(verbId: Int, tenseIds: [String]) -> [String: [String]] {
var map: [String: [String]] = [:]
for tenseId in tenseIds {
let forms = fetchForms(verbId: verbId, tenseId: tenseId)
.map(\.form)
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
if !forms.isEmpty { map[tenseId] = forms }
}
return map
}
func fetchForm(verbId: Int, tenseId: String, personIndex: Int) -> VerbForm? { func fetchForm(verbId: Int, tenseId: String, personIndex: Int) -> VerbForm? {
let descriptor = FetchDescriptor<VerbForm>( let descriptor = FetchDescriptor<VerbForm>(
predicate: #Predicate<VerbForm> { form in predicate: #Predicate<VerbForm> { form in
@@ -0,0 +1,59 @@
import Foundation
import SharedModels
/// Loads and queries the curated reflexive-verb list bundled with the app
/// (Gitea issue #28). One JSON load at init; in-memory lookup thereafter.
///
/// `entries(for:)` returns a list because a single base infinitive may map to
/// multiple reflexive entries e.g., `ponerse` covers both "to put on
/// (clothing) / to become" and "to come to an agreement (with)".
@MainActor
@Observable
final class ReflexiveVerbStore {
/// Process-wide accessor for services that can't use @Environment injection
/// (e.g. PracticeSessionService called from ViewModels). Views should still
/// prefer @Environment(ReflexiveVerbStore.self) for consistency.
static let shared = ReflexiveVerbStore()
private(set) var entries: [ReflexiveVerb] = []
private var indexByBase: [String: [ReflexiveVerb]] = [:]
/// Set of base infinitives present in the list. Cheap lookup for filters.
private(set) var baseInfinitives: Set<String> = []
init(bundle: Bundle = .main) {
load(from: bundle)
}
/// All reflexive entries whose base infinitive matches (case-insensitive).
func entries(for baseInfinitive: String) -> [ReflexiveVerb] {
indexByBase[baseInfinitive.lowercased()] ?? []
}
/// Convenience true when the verb's bare infinitive appears in the list.
func isReflexive(baseInfinitive: String) -> Bool {
baseInfinitives.contains(baseInfinitive.lowercased())
}
private func load(from bundle: Bundle) {
guard let url = bundle.url(forResource: "reflexive_verbs", withExtension: "json"),
let data = try? Data(contentsOf: url) else {
print("[ReflexiveVerbStore] bundled reflexive_verbs.json not found")
return
}
do {
let decoded = try JSONDecoder().decode([ReflexiveVerb].self, from: data)
entries = decoded
var index: [String: [ReflexiveVerb]] = [:]
for entry in decoded {
index[entry.baseInfinitive.lowercased(), default: []].append(entry)
}
indexByBase = index
baseInfinitives = Set(index.keys)
print("[ReflexiveVerbStore] loaded \(decoded.count) entries (\(baseInfinitives.count) distinct base infinitives)")
} catch {
print("[ReflexiveVerbStore] decode failed: \(error)")
}
}
}
+23 -7
View File
@@ -72,13 +72,13 @@ struct ReviewStore {
return newCard return newCard
} }
/// Bumps the streak / "showed up today" bookkeeping without touching
/// review-specific counters. Call from any user-initiated learning action
/// sending a chat message, doing an exercise, watching a curated video,
/// looking up a word in lyrics, etc. Safe to call multiple times per day;
/// only the first call on a fresh date moves the streak.
@discardableResult @discardableResult
static func updateProgress( static func recordActivity(context: ModelContext, date: Date = Date()) -> UserProgress {
reviewIncrement: Int,
correctIncrement: Int,
context: ModelContext,
date: Date = Date()
) -> UserProgress {
let progress = fetchOrCreateUserProgress(context: context) let progress = fetchOrCreateUserProgress(context: context)
let todayString = DailyLog.dateString(from: date) let todayString = DailyLog.dateString(from: date)
@@ -97,9 +97,25 @@ struct ReviewStore {
progress.todayCount = 0 progress.todayCount = 0
} }
progress.longestStreak = max(progress.longestStreak, progress.currentStreak)
try? context.save()
return progress
}
@discardableResult
static func updateProgress(
reviewIncrement: Int,
correctIncrement: Int,
context: ModelContext,
date: Date = Date()
) -> UserProgress {
// Bump streak / today-date first so review-specific counters land on
// the correct day if this is the user's first action after midnight.
let progress = recordActivity(context: context, date: date)
let todayString = DailyLog.dateString(from: date)
progress.todayCount += reviewIncrement progress.todayCount += reviewIncrement
progress.totalReviewed += reviewIncrement progress.totalReviewed += reviewIncrement
progress.longestStreak = max(progress.longestStreak, progress.currentStreak)
let log = fetchOrCreateDailyLog(dateString: todayString, context: context) let log = fetchOrCreateDailyLog(dateString: todayString, context: context)
log.reviewCount += reviewIncrement log.reviewCount += reviewIncrement
@@ -9,6 +9,8 @@ enum StartupCoordinator {
static func bootstrap(localContainer: ModelContainer) async { static func bootstrap(localContainer: ModelContainer) async {
await DataLoader.seedIfNeeded(container: localContainer) await DataLoader.seedIfNeeded(container: localContainer)
await DataLoader.refreshCourseDataIfNeeded(container: localContainer) await DataLoader.refreshCourseDataIfNeeded(container: localContainer)
await DataLoader.refreshTextbookDataIfNeeded(container: localContainer)
await DataLoader.refreshBooksDataIfNeeded(container: localContainer)
} }
/// Recurring maintenance: legacy migrations, identity repair, cloud dedup. /// Recurring maintenance: legacy migrations, identity repair, cloud dedup.
@@ -26,12 +26,14 @@ enum StoreInspector {
let hasZVERBFORM = tables.contains("ZVERBFORM") let hasZVERBFORM = tables.contains("ZVERBFORM")
let hasZTENSEGUIDE = tables.contains("ZTENSEGUIDE") let hasZTENSEGUIDE = tables.contains("ZTENSEGUIDE")
let hasZVOCABCARD = tables.contains("ZVOCABCARD") let hasZVOCABCARD = tables.contains("ZVOCABCARD")
let hasZTEXTBOOKCHAPTER = tables.contains("ZTEXTBOOKCHAPTER")
var summary = "[StoreInspector:\(label)] \(tables.count) tables" var summary = "[StoreInspector:\(label)] \(tables.count) tables"
summary += " | ZVERB=\(hasZVERB ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERB") : -1)" summary += " | ZVERB=\(hasZVERB ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERB") : -1)"
summary += " ZVERBFORM=\(hasZVERBFORM ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERBFORM") : -1)" summary += " ZVERBFORM=\(hasZVERBFORM ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERBFORM") : -1)"
summary += " ZTENSEGUIDE=\(hasZTENSEGUIDE ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZTENSEGUIDE") : -1)" summary += " ZTENSEGUIDE=\(hasZTENSEGUIDE ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZTENSEGUIDE") : -1)"
summary += " ZVOCABCARD=\(hasZVOCABCARD ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVOCABCARD") : -1)" summary += " ZVOCABCARD=\(hasZVOCABCARD ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVOCABCARD") : -1)"
summary += " ZTEXTBOOKCHAPTER=\(hasZTEXTBOOKCHAPTER ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZTEXTBOOKCHAPTER") : -1)"
print(summary) print(summary)
// Log all Z-tables (SwiftData entity tables start with Z, minus Core Data system tables) // Log all Z-tables (SwiftData entity tables start with Z, minus Core Data system tables)
@@ -0,0 +1,81 @@
import Foundation
import SharedModels
/// Disk-backed cache for verb example sentences (Issue #27). One JSON file
/// in the Caches directory keyed by verb id; lazy-loaded on first access and
/// write-through on every generation. Matches DictionaryService's disk pattern.
///
/// Cache eviction by the OS is acceptable because contents are regenerable.
@MainActor
@Observable
final class VerbExampleCache {
/// Bump to invalidate every cached example set. Raised to 2 for Issue #33
/// examples generated before the verb-grounding/validation fix could
/// contain sentences built on the wrong verb.
private static let cacheVersion = 2
private struct CacheFile: Codable {
var version: Int
var entries: [String: [VerbExample]]
}
private var store: [Int: [VerbExample]] = [:]
private var isLoaded = false
init() {}
// MARK: - Public API
/// Look up cached examples for a verb; returns nil on miss.
/// Safe to call before `loadIfNeeded()`; it triggers the disk load itself.
func examples(for verbId: Int) -> [VerbExample]? {
loadIfNeeded()
return store[verbId]
}
/// Store newly generated examples and persist to disk.
func setExamples(_ examples: [VerbExample], for verbId: Int) {
loadIfNeeded()
store[verbId] = examples
save()
}
// MARK: - Disk I/O
private static var cacheURL: URL {
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("verb_examples.json")
}
private func loadIfNeeded() {
guard !isLoaded else { return }
defer { isLoaded = true }
guard let data = try? Data(contentsOf: Self.cacheURL),
let decoded = try? JSONDecoder().decode(CacheFile.self, from: data),
decoded.version == Self.cacheVersion
else {
// Missing, unreadable, old flat format, or stale version start
// fresh so pre-fix examples don't linger.
return
}
// Persisted with String keys because JSON object keys are strings;
// convert back to Int for in-memory lookup.
var rebuilt: [Int: [VerbExample]] = [:]
for (key, value) in decoded.entries {
if let id = Int(key) {
rebuilt[id] = value
}
}
store = rebuilt
}
private func save() {
let entries = Dictionary(uniqueKeysWithValues: store.map { (String($0.key), $0.value) })
let file = CacheFile(version: Self.cacheVersion, entries: entries)
guard let data = try? JSONEncoder().encode(file) else { return }
try? data.write(to: Self.cacheURL)
}
}
@@ -0,0 +1,172 @@
import Foundation
import FoundationModels
import SharedModels
/// Generates a set of example sentences for a single verb, one per core tense
/// (Issue #27). Mirrors the StoryGenerator pattern: @Generable response types,
/// a static availability flag, and a single generate(...) entry point.
///
/// Issue #33: the model used to drift onto other verbs partway through the
/// 6-example batch (a "tener" set would contain sentences built on estar / ir
/// / deber). Two defenses now apply:
/// 1. The prompt embeds the verb's *exact* conjugated forms per tense, so
/// the model echoes a real form instead of recalling one.
/// 2. Every generated sentence is validated against those forms; failures
/// are regenerated once, and anything still wrong is dropped rather than
/// shown.
@MainActor
struct VerbExampleGenerator {
// MARK: - Generable Types
@Generable
struct GeneratedExampleSet {
@Guide(
description: "Six example sentences, one per tense in the exact order requested. Each sentence must actually use the target verb conjugated in that tense.",
.count(6)
)
var examples: [GeneratedExample]
}
@Generable
struct GeneratedExample {
@Guide(description: "The tense id this sentence demonstrates. Must match one of the ids provided in the prompt exactly (e.g. ind_presente).")
var tenseId: String
@Guide(description: "A natural Spanish sentence, 6-14 words, that uses the target verb in the specified tense. For imperative tenses use tú or nosotros — never yo.")
var spanish: String
@Guide(description: "An accurate, idiomatic English translation of the Spanish sentence.")
var english: String
}
// MARK: - Generation
/// Generate one validated example per tense in `tenseIds`.
///
/// - Parameter formsByTense: the verb's conjugated forms keyed by tenseId
/// (from `ReferenceStore.conjugatedForms`). Used to ground the prompt
/// and validate the output. A tense with no forms here is accepted
/// without validation.
static func generate(
verbInfinitive: String,
verbEnglish: String,
tenseIds: [String],
formsByTense: [String: [String]]
) async throws -> [VerbExample] {
// A thrown error here (model busy, context overflow) shouldn't abort the
// whole generation treat it as a fully-failed batch so the retry below
// still runs, same as the retry path's own `try?`.
let firstPass = (try? await generateBatch(
verbInfinitive: verbInfinitive,
verbEnglish: verbEnglish,
tenseIds: tenseIds,
formsByTense: formsByTense
)) ?? [:]
var valid: [String: VerbExample] = [:]
var failedTenses: [String] = []
for id in tenseIds {
if let ex = firstPass[id], exampleUsesVerb(ex.spanish, forms: formsByTense[id] ?? []) {
valid[id] = ex
} else {
failedTenses.append(id)
}
}
// One focused retry regenerate the whole batch, but only adopt the
// results for tenses that failed the first pass.
if !failedTenses.isEmpty {
let retry = try? await generateBatch(
verbInfinitive: verbInfinitive,
verbEnglish: verbEnglish,
tenseIds: tenseIds,
formsByTense: formsByTense
)
if let retry {
for id in failedTenses {
if let ex = retry[id], exampleUsesVerb(ex.spanish, forms: formsByTense[id] ?? []) {
valid[id] = ex
}
}
}
}
// Requested order, dropping any tense that never produced a valid
// sentence better to show fewer examples than wrong ones.
return tenseIds.compactMap { valid[$0] }
}
// MARK: - Single batch call
private static func generateBatch(
verbInfinitive: String,
verbEnglish: String,
tenseIds: [String],
formsByTense: [String: [String]]
) async throws -> [String: VerbExample] {
let tenseBlock = tenseIds.compactMap { id -> String? in
guard let info = TenseInfo.find(id) else { return nil }
let forms = formsByTense[id] ?? []
if forms.isEmpty {
return "- \(id) (\(info.english))"
}
return "- \(id) (\(info.english)): use one of these exact conjugated forms — \(forms.joined(separator: ", "))"
}.joined(separator: "\n")
let session = LanguageModelSession(instructions: """
You are a Spanish language teacher writing short example sentences for a learner.
The learner is studying the verb "\(verbInfinitive)" (to \(verbEnglish)).
EVERY sentence must use "\(verbInfinitive)" as its main verb, conjugated in the
requested tense — never substitute a different verb (no estar, ir, deber, etc.
unless the target verb itself is that verb). Each sentence must:
- Contain one of the exact conjugated forms listed for its tense.
- Be 6-14 words, natural and everyday.
- Use vocabulary appropriate for intermediate learners.
- Vary subjects and contexts across the set; do not reuse the same subject twice.
For imperative tenses, address "" or "nosotros" — never "yo".
""")
let prompt = """
Write one example sentence for "\(verbInfinitive)" per tense below, in this order:
\(tenseBlock)
Return one GeneratedExample per tense with the matching tenseId, spanish, and english.
The Spanish sentence MUST contain one of the conjugated forms shown for that tense.
"""
let response = try await session.respond(to: prompt, generating: GeneratedExampleSet.self)
// `uniquingKeysWith` defensively the schema forces 6 examples even
// when fewer tenses are requested, so the model may repeat a tenseId.
return Dictionary(
response.content.examples.map {
($0.tenseId, VerbExample(tenseId: $0.tenseId, spanish: $0.spanish, english: $0.english))
},
uniquingKeysWith: { first, _ in first }
)
}
// MARK: - Validation
/// True when `sentence` contains at least one of `forms` as a whole word
/// (accent- and case-insensitive). Empty `forms` accept (can't validate).
static func exampleUsesVerb(_ sentence: String, forms: [String]) -> Bool {
guard !forms.isEmpty else { return true }
let sentenceWords = foldedWords(sentence)
let formWords = Set(forms.flatMap { foldedWords($0) })
return !sentenceWords.isDisjoint(with: formWords)
}
private static func foldedWords(_ text: String) -> Set<String> {
let folded = text.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: nil
)
return Set(folded.split { !$0.isLetter }.map(String.init))
}
static var isAvailable: Bool {
SystemLanguageModel.default.availability == .available
}
}
@@ -0,0 +1,39 @@
import Foundation
import SharedModels
import SwiftData
/// SRS rating for verb-level vocab practice. Mirrors `CourseReviewStore` but
/// keyed by `verbId` (the integer primary key on `Verb`).
struct VerbReviewStore {
let context: ModelContext
@discardableResult
func fetchOrCreateReviewCard(verbId: Int) -> VerbReviewCard {
let id = VerbReviewCard.makeId(verbId: verbId)
let descriptor = FetchDescriptor<VerbReviewCard>(
predicate: #Predicate<VerbReviewCard> { $0.id == id }
)
if let existing = (try? context.fetch(descriptor))?.first {
return existing
}
let card = VerbReviewCard(verbId: verbId)
context.insert(card)
return card
}
func rate(verbId: Int, quality: ReviewQuality) {
let card = fetchOrCreateReviewCard(verbId: verbId)
let result = SRSEngine.review(
quality: quality,
currentEase: card.easeFactor,
currentInterval: card.interval,
currentReps: card.repetitions
)
card.easeFactor = result.easeFactor
card.interval = result.interval
card.repetitions = result.repetitions
card.dueDate = SRSEngine.nextDueDate(interval: result.interval)
card.lastReviewDate = Date()
try? context.save()
}
}
@@ -0,0 +1,363 @@
import Foundation
import SwiftData
import SharedModels
import AVFoundation
import YouTubeKit
/// Downloads YouTube videos for offline viewing (Issue #21, updated for #30).
///
/// Two-path strategy since YouTube phased out high-quality progressive streams:
/// 1. **Progressive** single MP4 with audio+video combined (rare, 360p).
/// Download directly to the final path.
/// 2. **Adaptive** DASH: separate video + audio tracks. Download each to
/// temp files, then mux with AVAssetExportSession (`.passthrough` preset,
/// no re-encoding) into the final MP4.
///
/// YouTubeKit returns stream URLs only; combining tracks is the app's job.
/// See the library's README Example 3 for the progressive-only pattern.
///
/// **Known fragility**: YouTubeKit scrapes YouTube's private stream API and
/// will break when YouTube changes their internal format. Streaming (iframe
/// embed elsewhere) keeps working regardless.
@MainActor
@Observable
final class VideoDownloadService {
// MARK: - Types
/// Per-download state surfaced to the UI. `progress == nil` means show an
/// indeterminate spinner (e.g. muxing phase).
struct DownloadStatus: Equatable, Sendable {
var progress: Double?
var label: String
}
enum DownloadError: Error, LocalizedError {
case extractionFailed(String)
case noSuitableStream
case downloadFailed(String)
case muxFailed(String)
case fileWriteFailed(String)
var errorDescription: String? {
switch self {
case .extractionFailed(let why): "Could not extract video: \(why)"
case .noSuitableStream: "No downloadable video+audio streams found for this video."
case .downloadFailed(let why): "Download failed: \(why)"
case .muxFailed(let why): "Could not combine audio and video: \(why)"
case .fileWriteFailed(let why): "Could not save video: \(why)"
}
}
}
/// In-flight downloads by videoId, with a phase label and progress.
var activeDownloads: [String: DownloadStatus] = [:]
static let shared = VideoDownloadService()
// MARK: - Paths
private static var videosDirectory: URL {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
return docs.appendingPathComponent("videos", isDirectory: true)
}
private static var tempDirectory: URL {
FileManager.default.temporaryDirectory
}
private static func ensureDirectory() throws {
let url = videosDirectory
if !FileManager.default.fileExists(atPath: url.path) {
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
}
}
static func fileURL(for videoId: String) -> URL {
videosDirectory.appendingPathComponent("\(videoId).mp4")
}
private static func tempURL(videoId: String, kind: String, ext: String) -> URL {
tempDirectory.appendingPathComponent("\(videoId).\(kind).\(ext)")
}
/// True if a downloaded MP4 exists for this videoId.
static func isDownloaded(videoId: String) -> Bool {
FileManager.default.fileExists(atPath: fileURL(for: videoId).path)
}
// MARK: - Public download entry point
func download(
videoId: String,
title: String,
into modelContext: ModelContext
) async throws {
guard activeDownloads[videoId] == nil else { return }
activeDownloads[videoId] = DownloadStatus(progress: 0, label: "Preparing…")
defer { activeDownloads.removeValue(forKey: videoId) }
try Self.ensureDirectory()
let destURL = Self.fileURL(for: videoId)
// Resolve streams once; decide progressive vs adaptive.
let plan: DownloadPlan
do {
plan = try await Self.resolvePlan(videoId: videoId)
} catch let e as DownloadError {
throw e
} catch {
throw DownloadError.extractionFailed(error.localizedDescription)
}
switch plan {
case .progressive(let url):
activeDownloads[videoId] = DownloadStatus(progress: 0, label: "Downloading…")
try await downloadStream(from: url, to: destURL) { [weak self] p in
Task { @MainActor [weak self] in
self?.activeDownloads[videoId] = DownloadStatus(
progress: p,
label: "Downloading \(Int(p * 100))%"
)
}
}
case .adaptive(let videoURL, let audioURL):
let tempVideo = Self.tempURL(videoId: videoId, kind: "video", ext: "mp4")
let tempAudio = Self.tempURL(videoId: videoId, kind: "audio", ext: "m4a")
defer {
try? FileManager.default.removeItem(at: tempVideo)
try? FileManager.default.removeItem(at: tempAudio)
}
// Phase 1 of 3: video track (055% of overall progress)
try await downloadStream(from: videoURL, to: tempVideo) { [weak self] p in
Task { @MainActor [weak self] in
self?.activeDownloads[videoId] = DownloadStatus(
progress: p * 0.55,
label: "Video \(Int(p * 100))%"
)
}
}
// Phase 2 of 3: audio track (5580%)
try await downloadStream(from: audioURL, to: tempAudio) { [weak self] p in
Task { @MainActor [weak self] in
self?.activeDownloads[videoId] = DownloadStatus(
progress: 0.55 + p * 0.25,
label: "Audio \(Int(p * 100))%"
)
}
}
// Phase 3 of 3: mux (indeterminate)
activeDownloads[videoId] = DownloadStatus(progress: nil, label: "Finalizing…")
do {
try await Self.mux(videoURL: tempVideo, audioURL: tempAudio, to: destURL)
} catch let e as DownloadError {
throw e
} catch {
throw DownloadError.muxFailed(error.localizedDescription)
}
}
// Record in SwiftData.
let attrs = try? FileManager.default.attributesOfItem(atPath: destURL.path)
let byteCount = (attrs?[.size] as? Int) ?? 0
let entry = DownloadedVideo(
videoId: videoId,
title: title,
filename: "\(videoId).mp4",
byteCount: byteCount
)
modelContext.insert(entry)
try? modelContext.save()
}
/// Deletes the downloaded file and its SwiftData row.
func delete(videoId: String, modelContext: ModelContext) {
let url = Self.fileURL(for: videoId)
try? FileManager.default.removeItem(at: url)
let descriptor = FetchDescriptor<DownloadedVideo>(
predicate: #Predicate<DownloadedVideo> { $0.videoId == videoId }
)
if let existing = try? modelContext.fetch(descriptor) {
for entry in existing {
modelContext.delete(entry)
}
try? modelContext.save()
}
}
/// Total bytes used by all downloads.
static func totalBytesUsed() -> Int {
let url = videosDirectory
guard let contents = try? FileManager.default.contentsOfDirectory(
at: url, includingPropertiesForKeys: [.fileSizeKey]
) else { return 0 }
return contents.reduce(0) { acc, file in
let size = (try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
return acc + size
}
}
// MARK: - Stream resolution
private enum DownloadPlan {
case progressive(URL)
case adaptive(video: URL, audio: URL)
}
/// Picks progressive MP4 if available (single download); otherwise the
/// best adaptive MP4 video + M4A audio pair for later muxing.
nonisolated private static func resolvePlan(videoId: String) async throws -> DownloadPlan {
let youtube = YouTube(videoID: videoId)
let streams = try await youtube.streams
// 1. Progressive path uses the library's own recommended filter chain.
if let progressive = streams
.filterVideoAndAudio()
.filter({ $0.fileExtension == .mp4 && $0.isNativelyPlayable })
.highestResolutionStream() {
return .progressive(progressive.url)
}
// 2. Adaptive path highest-resolution MP4 video track + highest-bitrate M4A audio.
let videoStream = streams
.filterVideoOnly()
.filter { $0.fileExtension == .mp4 && $0.isNativelyPlayable }
.highestResolutionStream()
let audioStream = streams
.filterAudioOnly()
.filter {
($0.fileExtension == .m4a || $0.fileExtension == .mp4) && $0.isNativelyPlayable
}
.highestAudioBitrateStream()
if let v = videoStream, let a = audioStream {
return .adaptive(video: v.url, audio: a.url)
}
throw DownloadError.noSuitableStream
}
// MARK: - Download helper
nonisolated private func downloadStream(
from url: URL,
to dest: URL,
onProgress: @escaping @Sendable (Double) -> Void
) async throws {
let delegate = DownloadProgressDelegate(onProgress: onProgress)
do {
let (tempURL, _) = try await URLSession.shared.download(
for: URLRequest(url: url),
delegate: delegate
)
if FileManager.default.fileExists(atPath: dest.path) {
try FileManager.default.removeItem(at: dest)
}
try FileManager.default.moveItem(at: tempURL, to: dest)
} catch {
throw DownloadError.downloadFailed(error.localizedDescription)
}
}
// MARK: - Muxing
/// Combines a video-only file and an audio-only file into a single MP4.
/// Uses `.passthrough` preset no re-encoding, just container rewrite.
/// Falls back to `.highestQuality` (which re-encodes) if passthrough
/// rejects the composition due to codec incompatibility.
nonisolated private static func mux(
videoURL: URL,
audioURL: URL,
to outputURL: URL
) async throws {
let videoAsset = AVURLAsset(url: videoURL)
let audioAsset = AVURLAsset(url: audioURL)
let videoTracks = try await videoAsset.loadTracks(withMediaType: .video)
let audioTracks = try await audioAsset.loadTracks(withMediaType: .audio)
guard let srcVideo = videoTracks.first,
let srcAudio = audioTracks.first else {
throw DownloadError.muxFailed("Downloaded streams missing expected tracks")
}
let composition = AVMutableComposition()
guard let compVideo = composition.addMutableTrack(
withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid
),
let compAudio = composition.addMutableTrack(
withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid
) else {
throw DownloadError.muxFailed("Could not create composition tracks")
}
let videoDuration = try await videoAsset.load(.duration)
let audioDuration = try await audioAsset.load(.duration)
let duration = CMTimeMinimum(videoDuration, audioDuration)
let range = CMTimeRange(start: .zero, duration: duration)
try compVideo.insertTimeRange(range, of: srcVideo, at: .zero)
try compAudio.insertTimeRange(range, of: srcAudio, at: .zero)
if FileManager.default.fileExists(atPath: outputURL.path) {
try FileManager.default.removeItem(at: outputURL)
}
// Try passthrough (no re-encode) first; fall back to quality export on failure.
let presets = [AVAssetExportPresetPassthrough, AVAssetExportPresetHighestQuality]
var lastError: Error?
for preset in presets {
guard let exporter = AVAssetExportSession(asset: composition, presetName: preset) else {
continue
}
do {
try await exporter.export(to: outputURL, as: .mp4)
return
} catch {
lastError = error
// Clean up partial output before retrying with next preset.
try? FileManager.default.removeItem(at: outputURL)
continue
}
}
throw DownloadError.muxFailed(lastError?.localizedDescription ?? "Unknown export failure")
}
}
// MARK: - URLSession progress delegate
private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
let onProgress: @Sendable (Double) -> Void
init(onProgress: @escaping @Sendable (Double) -> Void) {
self.onProgress = onProgress
}
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64
) {
guard totalBytesExpectedToWrite > 0 else { return }
onProgress(Double(totalBytesWritten) / Double(totalBytesExpectedToWrite))
}
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
// Not used `URLSession.download(for:delegate:)` returns the temp URL directly.
}
}
@@ -0,0 +1,216 @@
import Foundation
import SharedModels
import SwiftData
/// In-session "learning steps" queue for vocab practice the short-term
/// scheduling layer that sits on top of the cross-session SM-2 schedule.
///
/// A card is requeued a relative number of positions ahead based on the
/// rating, mirroring Anki's learning steps but position-based instead of
/// time-based:
/// Again reappears 58 cards later
/// Hard reappears 710 cards later
/// Good first time: advances to `review`, reappears ~20 cards later
/// already in review: graduates (leaves the session)
/// Easy graduates immediately
///
/// `answer` returns a `ReviewQuality` only when the card graduates that's
/// the single rating fed to the long-term `VerbReviewStore`. Intermediate
/// Again/Hard presses don't touch the cross-session schedule.
struct VocabSessionQueue {
enum CardState: String {
case new // never answered this session
case learning // answered Again/Hard at least once
case review // answered Good once, one confirmation pass to go
}
enum Rating {
case again, hard, good, easy
}
struct Entry: Identifiable {
let id = UUID()
let verb: Verb
var state: CardState
}
private(set) var queue: [Entry]
private(set) var learnedCount: Int = 0
private let originalVerbs: [Verb]
init(verbs: [Verb]) {
originalVerbs = verbs
queue = verbs.map { Entry(verb: $0, state: .new) }
}
/// Resume a persisted group: rebuild the queue from saved (verb, state)
/// pairs in order, restoring the learned count. The exact requeue
/// positions aren't persisted the queue order itself is what's saved.
init(entries: [(verb: Verb, state: CardState)], learnedCount: Int) {
originalVerbs = entries.map(\.verb)
queue = entries.map { Entry(verb: $0.verb, state: $0.state) }
self.learnedCount = learnedCount
}
/// Current queue (un-graduated cards) in order for persistence.
func snapshot() -> [(verbId: Int, state: CardState)] {
queue.map { ($0.verb.id, $0.state) }
}
// MARK: - State
var current: Entry? { queue.first }
var isComplete: Bool { queue.isEmpty }
var remainingCount: Int { queue.count }
/// 01, climbs as cards graduate. Requeuing a card lowers it slightly but
/// it always trends to 1 as the session drains.
var progress: Double {
let total = learnedCount + queue.count
return total == 0 ? 1 : Double(learnedCount) / Double(total)
}
// MARK: - Answering
/// Apply a rating to the current card. Returns the `ReviewQuality` to
/// record in the long-term SRS *iff* the card graduated; nil if it was
/// requeued for more in-session practice.
@discardableResult
mutating func answer(_ rating: Rating) -> ReviewQuality? {
guard !queue.isEmpty else { return nil }
var entry = queue.removeFirst()
switch rating {
// Again/Hard always (re)set the card to `.learning`. A card already in
// `.review` therefore drops back a step an intentional lapse, matching
// Anki: missing a card you'd previously passed sends it back through the
// learning steps rather than letting it graduate.
case .again:
entry.state = .learning
insert(entry, offset: Int.random(in: 5...8))
return nil
case .hard:
entry.state = .learning
insert(entry, offset: Int.random(in: 7...10))
return nil
case .good:
if entry.state == .review {
learnedCount += 1
return .good
}
entry.state = .review
insert(entry, offset: Int.random(in: 16...24))
return nil
case .easy:
learnedCount += 1
return .easy
}
}
/// Rebuild the session from the same verb set (re-shuffled) "Study Again".
mutating func restart() {
queue = originalVerbs.shuffled().map { Entry(verb: $0, state: .new) }
learnedCount = 0
}
// MARK: - Private
/// Insert `entry` `offset` positions from the front, i.e. `offset` other
/// cards will be shown before it reappears. Clamps to the queue's end.
private mutating func insert(_ entry: Entry, offset: Int) {
let idx = min(queue.count, offset)
queue.insert(entry, at: idx)
}
}
// MARK: - Session verb pool
/// Builds a vocab-practice session: level-filtered verbs ordered due-first
/// (per the cross-session `VerbReviewCard` schedule) and capped so a single
/// sitting is bounded proper SRS behaviour rather than a 100+ card slog.
enum VocabVerbPool {
/// Maximum verbs in one session, from the "Cards per session" setting
/// (`vocabSessionCardLimit`). Defaults to 20 when unset; 999 means "All".
/// Overdue cards are pulled first, then new (never-reviewed) verbs.
static var sessionCardLimit: Int {
let stored = UserDefaults.standard.integer(forKey: "vocabSessionCardLimit")
return stored == 0 ? 20 : stored
}
static func sessionVerbs(
localContext: ModelContext,
cloudContext: ModelContext
) -> [Verb] {
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let levels = Set(progress.selectedVerbLevels.map(\.rawValue))
let store = ReferenceStore(context: localContext)
let pool = levels.isEmpty
? store.fetchVerbs()
: store.fetchVerbs(selectedLevels: levels)
let reviewCards = (try? cloudContext.fetch(FetchDescriptor<VerbReviewCard>())) ?? []
let cardByVerbId = Dictionary(
reviewCards.map { ($0.verbId, $0) },
uniquingKeysWith: { existing, _ in existing }
)
let now = Date()
var due: [(verb: Verb, dueDate: Date)] = []
var fresh: [Verb] = []
for verb in pool {
if let card = cardByVerbId[verb.id] {
if card.dueDate <= now {
due.append((verb, card.dueDate))
}
// Not yet due intentionally skipped; that's the SRS schedule.
} else {
fresh.append(verb)
}
}
// Most-overdue first, then new verbs (lower rank = more common first).
due.sort { $0.dueDate < $1.dueDate }
fresh.sort { $0.rank < $1.rank }
let ordered = due.map(\.verb) + fresh
return Array(ordered.prefix(sessionCardLimit))
}
/// Verbs the user has already studied at least once (have a
/// `VerbReviewCard`), most-recently-studied first. Used by the
/// "Review Learned" consolidation pass ignores due dates and the
/// Level filter, and is uncapped: it's a deliberate cram over
/// everything you've learned.
static func reviewLearnedVerbs(
localContext: ModelContext,
cloudContext: ModelContext
) -> [Verb] {
let reviewCards = (try? cloudContext.fetch(FetchDescriptor<VerbReviewCard>())) ?? []
let sorted = reviewCards.sorted {
($0.lastReviewDate ?? .distantPast) > ($1.lastReviewDate ?? .distantPast)
}
let allVerbs = ReferenceStore(context: localContext).fetchVerbs()
let byId = Dictionary(allVerbs.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
return sorted.compactMap { byId[$0.verbId] }
}
}
/// Canonical 6-tense set for `VerbExampleGenerator`. Its `@Generable` schema
/// requires exactly 6 examples, so callers must pass 6 distinct tense IDs.
enum VocabExampleTenseIds {
static let canonical: [String] = [
TenseID.ind_presente.rawValue,
TenseID.ind_preterito.rawValue,
TenseID.ind_imperfecto.rawValue,
TenseID.ind_futuro.rawValue,
TenseID.subj_presente.rawValue,
TenseID.imp_afirmativo.rawValue,
]
}
@@ -0,0 +1,61 @@
import Foundation
/// Curated YouTube-video lookup for guide + grammar items (Issue #21).
/// Loads the bundled `youtube_videos.json` at init, serves tense-guide and
/// grammar-note videos by id. The data is static after load; `static let shared`
/// lets services access it without environment injection.
@MainActor
@Observable
final class YouTubeVideoStore {
struct VideoEntry: Codable, Hashable, Sendable, Identifiable {
let videoId: String
let title: String
var id: String { videoId }
}
static let shared = YouTubeVideoStore()
private(set) var tenseVideos: [String: VideoEntry] = [:]
private(set) var grammarVideos: [String: VideoEntry] = [:]
init(bundle: Bundle = .main) {
load(from: bundle)
}
/// Returns the curated video for a tense guide, or nil if unmapped.
func video(forTenseId id: String) -> VideoEntry? {
tenseVideos[id]
}
/// Returns the curated video for a grammar note, or nil if unmapped.
func video(forGrammarNoteId id: String) -> VideoEntry? {
grammarVideos[id]
}
/// All distinct videoIds present in the store. Useful for bulk operations
/// like "download all" or cache cleanup.
var allVideoIds: Set<String> {
Set(tenseVideos.values.map(\.videoId)).union(grammarVideos.values.map(\.videoId))
}
private func load(from bundle: Bundle) {
guard let url = bundle.url(forResource: "youtube_videos", withExtension: "json"),
let data = try? Data(contentsOf: url) else {
print("[YouTubeVideoStore] bundled youtube_videos.json not found")
return
}
struct Root: Decodable {
let tenseGuides: [String: VideoEntry]
let grammarNotes: [String: VideoEntry]
}
do {
let root = try JSONDecoder().decode(Root.self, from: data)
tenseVideos = root.tenseGuides
grammarVideos = root.grammarNotes
print("[YouTubeVideoStore] loaded \(tenseVideos.count) tense + \(grammarVideos.count) grammar entries")
} catch {
print("[YouTubeVideoStore] decode failed: \(error)")
}
}
}
@@ -96,7 +96,11 @@ final class PracticeViewModel {
currentSpans = [] currentSpans = []
hasCards = true hasCards = true
isLoading = true isLoading = true
let service = PracticeSessionService(localContext: localContext, cloudContext: cloudContext) let service = PracticeSessionService(
localContext: localContext,
cloudContext: cloudContext,
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
)
guard let cardLoad = service.nextCard(for: focusMode, excludingVerbId: currentVerb?.id) else { guard let cardLoad = service.nextCard(for: focusMode, excludingVerbId: currentVerb?.id) else {
clearCurrentCard() clearCurrentCard()
hasCards = false hasCards = false
@@ -585,6 +585,7 @@ struct CourseQuizView: View {
) )
cloudModelContext.insert(result) cloudModelContext.insert(result)
try? cloudModelContext.save() try? cloudModelContext.save()
ReviewStore.recordActivity(context: cloudModelContext)
} }
} }
+101 -1
View File
@@ -5,8 +5,18 @@ import SwiftData
struct CourseView: View { struct CourseView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Query(sort: \CourseDeck.weekNumber) private var decks: [CourseDeck] @Query(sort: \CourseDeck.weekNumber) private var decks: [CourseDeck]
@Query(sort: \TextbookChapter.number) private var textbookChapters: [TextbookChapter]
@AppStorage("selectedCourse") private var selectedCourse: String? @AppStorage("selectedCourse") private var selectedCourse: String?
@State private var testResults: [TestResult] = [] @State private var testResults: [TestResult] = []
@State private var extraStudyCounts: [Int: Int] = [:]
private var textbookCourses: [String] {
Array(Set(textbookChapters.map(\.courseName))).sorted()
}
private var activeCourseIsTextbook: Bool {
textbookCourses.contains(activeCourse)
}
private var cloudModelContext: ModelContext { cloudModelContextProvider() } private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@@ -62,6 +72,32 @@ struct CourseView: View {
description: Text("Course data is loading...") description: Text("Course data is loading...")
) )
} else { } else {
// Textbook entry (shown above course picker when available)
if !textbookCourses.isEmpty {
Section {
ForEach(textbookCourses, id: \.self) { name in
NavigationLink(value: TextbookDestination(courseName: name)) {
HStack(spacing: 12) {
Image(systemName: "book.fill")
.font(.title3)
.foregroundStyle(.indigo)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(name)
.font(.subheadline.weight(.semibold))
Text("Read chapters, do exercises")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
}
}
} header: {
Text("Textbook")
}
}
// Course picker // Course picker
if courseNames.count > 1 { if courseNames.count > 1 {
Section { Section {
@@ -138,6 +174,28 @@ struct CourseView: View {
} }
} }
} }
// Extra Study row only when there are marks for this week
if !activeCourseIsTextbook, let markCount = extraStudyCounts[week], markCount > 0 {
NavigationLink(value: ExtraStudyDestination(courseName: activeCourse, weekNumber: week)) {
HStack(spacing: 12) {
Image(systemName: "star.fill")
.font(.title3)
.foregroundStyle(.yellow)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Extra Study")
.font(.subheadline.weight(.semibold))
Text("\(markCount) marked card\(markCount == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
}
}
} header: { } header: {
Text("Week \(week)") Text("Week \(week)")
} }
@@ -145,22 +203,55 @@ struct CourseView: View {
} }
} }
.navigationTitle(shortName(activeCourse).isEmpty ? "Course" : shortName(activeCourse)) .navigationTitle(shortName(activeCourse).isEmpty ? "Course" : shortName(activeCourse))
.onAppear(perform: loadTestResults) .onAppear {
loadTestResults()
loadExtraStudyCounts()
}
.onChange(of: activeCourse) { _, _ in
loadExtraStudyCounts()
}
.navigationDestination(for: CourseDeck.self) { deck in .navigationDestination(for: CourseDeck.self) { deck in
DeckStudyView(deck: deck) DeckStudyView(deck: deck)
} }
.navigationDestination(for: ExtraStudyDestination.self) { dest in
ExtraStudyView(courseName: dest.courseName, weekNumber: dest.weekNumber)
}
.navigationDestination(for: WeekTestDestination.self) { dest in .navigationDestination(for: WeekTestDestination.self) { dest in
WeekTestView(courseName: dest.courseName, weekNumber: dest.weekNumber) WeekTestView(courseName: dest.courseName, weekNumber: dest.weekNumber)
} }
.navigationDestination(for: CheckpointDestination.self) { dest in .navigationDestination(for: CheckpointDestination.self) { dest in
CheckpointExamView(courseName: dest.courseName, throughWeek: dest.throughWeek) CheckpointExamView(courseName: dest.courseName, throughWeek: dest.throughWeek)
} }
.navigationDestination(for: TextbookDestination.self) { dest in
TextbookChapterListView(courseName: dest.courseName)
}
.navigationDestination(for: TextbookChapter.self) { chapter in
TextbookChapterView(chapter: chapter)
}
.navigationDestination(for: TextbookExerciseDestination.self) { dest in
textbookExerciseView(for: dest)
}
}
}
@ViewBuilder
private func textbookExerciseView(for dest: TextbookExerciseDestination) -> some View {
if let chapter = textbookChapters.first(where: { $0.id == dest.chapterId }) {
TextbookExerciseView(chapter: chapter, blockIndex: dest.blockIndex)
} else {
ContentUnavailableView("Exercise unavailable", systemImage: "questionmark.circle")
} }
} }
private func loadTestResults() { private func loadTestResults() {
testResults = (try? cloudModelContext.fetch(FetchDescriptor<TestResult>())) ?? [] testResults = (try? cloudModelContext.fetch(FetchDescriptor<TestResult>())) ?? []
} }
private func loadExtraStudyCounts() {
guard !activeCourse.isEmpty else { return }
extraStudyCounts = ExtraStudyStore(context: cloudModelContext)
.countsByWeek(courseName: activeCourse)
}
} }
// MARK: - Navigation // MARK: - Navigation
@@ -175,6 +266,15 @@ struct CheckpointDestination: Hashable {
let throughWeek: Int let throughWeek: Int
} }
struct TextbookDestination: Hashable {
let courseName: String
}
struct ExtraStudyDestination: Hashable {
let courseName: String
let weekNumber: Int
}
// MARK: - Deck Row // MARK: - Deck Row
private struct DeckRowView: View { private struct DeckRowView: View {
@@ -5,9 +5,29 @@ import SwiftData
struct DeckStudyView: View { struct DeckStudyView: View {
let deck: CourseDeck let deck: CourseDeck
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Query private var textbookChapters: [TextbookChapter]
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var isStudying = false @State private var isStudying = false
@State private var speechService = SpeechService() @State private var speechService = SpeechService()
@State private var deckCards: [VocabCard] = [] @State private var deckCards: [VocabCard] = []
@State private var expandedConjugations: Set<String> = []
private var isStemChangingDeck: Bool {
deck.title.localizedCaseInsensitiveContains("stem changing")
}
private var isTextbookDeck: Bool {
textbookChapters.contains { $0.courseName == deck.courseName }
}
private var markContext: ExtraStudyMarkContext? {
guard !isTextbookDeck else { return nil }
return ExtraStudyMarkContext(
courseName: deck.courseName,
weekNumber: deck.weekNumber
)
}
var body: some View { var body: some View {
cardListView cardListView
@@ -19,7 +39,12 @@ struct DeckStudyView: View {
VocabFlashcardView( VocabFlashcardView(
cards: deckCards.shuffled(), cards: deckCards.shuffled(),
speechService: speechService, speechService: speechService,
onDone: { isStudying = false } onDone: {
ReviewStore.recordActivity(context: cloudModelContext)
isStudying = false
},
deckTitle: deck.title,
markContext: markContext
) )
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
@@ -30,6 +55,24 @@ struct DeckStudyView: View {
} }
} }
/// Reversed stem-change decks have `front` as English, so prefer the
/// Spanish side when the card is stored that way. Strip parenthetical
/// notes and the reflexive `-se` ending for verb-table lookup.
private func inferInfinitive(card: VocabCard) -> String {
let raw: String
if deck.isReversed {
raw = card.back
} else {
raw = card.front
}
var t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if let paren = t.firstIndex(of: "(") {
t = String(t[..<paren]).trimmingCharacters(in: .whitespacesAndNewlines)
}
if t.hasSuffix("se") && t.count > 4 { t = String(t.dropLast(2)) }
return t
}
private func loadCards() { private func loadCards() {
let deckId = deck.id let deckId = deck.id
let descriptor = FetchDescriptor<VocabCard>( let descriptor = FetchDescriptor<VocabCard>(
@@ -107,6 +150,36 @@ struct DeckStudyView: View {
.multilineTextAlignment(.trailing) .multilineTextAlignment(.trailing)
} }
// Stem-change conjugation toggle
if isStemChangingDeck {
let verb = inferInfinitive(card: card)
let isOpen = expandedConjugations.contains(verb)
Button {
withAnimation(.smooth) {
if isOpen {
expandedConjugations.remove(verb)
} else {
expandedConjugations.insert(verb)
}
}
} label: {
Label(
isOpen ? "Hide conjugation" : "Show conjugation",
systemImage: isOpen ? "chevron.up" : "chevron.down"
)
.font(.caption.weight(.medium))
}
.buttonStyle(.borderless)
.tint(.blue)
.padding(.leading, 42)
if isOpen {
StemChangeConjugationView(infinitive: verb)
.padding(.leading, 42)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
// Example sentences // Example sentences
if !card.examplesES.isEmpty { if !card.examplesES.isEmpty {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
@@ -0,0 +1,75 @@
import SwiftUI
import SharedModels
import SwiftData
/// Study session containing only cards the user has marked for extra study,
/// scoped to a specific (courseName, weekNumber). Resolves marks by re-hashing
/// each VocabCard via `CourseCardStore.reviewKey` so the matching is robust to
/// duplicate (deckId, front, back) tuples that differ in examples.
struct ExtraStudyView: View {
let courseName: String
let weekNumber: Int
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss
@State private var cards: [VocabCard] = []
@State private var speechService = SpeechService()
@State private var loaded = false
private var cloudContext: ModelContext { cloudModelContextProvider() }
var body: some View {
Group {
if !loaded {
ProgressView()
.controlSize(.large)
} else if cards.isEmpty {
ContentUnavailableView(
"No Marked Cards",
systemImage: "star",
description: Text("Tap the star on a card during study to add it here.")
)
} else {
VocabFlashcardView(
cards: cards.shuffled(),
speechService: speechService,
onDone: {
ReviewStore.recordActivity(context: cloudContext)
dismiss()
},
deckTitle: "Extra Study",
markContext: ExtraStudyMarkContext(
courseName: courseName,
weekNumber: weekNumber
)
)
}
}
.navigationTitle("Extra Study · Week \(weekNumber)")
.navigationBarTitleDisplayMode(.inline)
.task { load() }
}
private func load() {
guard !loaded else { return }
let marks = ExtraStudyStore(context: cloudContext)
.fetch(courseName: courseName, weekNumber: weekNumber)
let markIds = Set(marks.map(\.id))
let deckIds = Set(marks.map(\.deckId))
var collected: [VocabCard] = []
for deckId in deckIds {
let descriptor = FetchDescriptor<VocabCard>(
predicate: #Predicate<VocabCard> { $0.deckId == deckId }
)
let deckCards = (try? localContext.fetch(descriptor)) ?? []
collected.append(contentsOf: deckCards.filter {
markIds.contains(CourseCardStore.reviewKey(for: $0))
})
}
cards = collected
loaded = true
}
}
@@ -0,0 +1,97 @@
import SwiftUI
import SharedModels
import SwiftData
/// Shows the present-tense conjugation of a verb (identified by infinitive),
/// with any irregular/stem-change spans highlighted. Designed to drop into
/// stem-changing verb flashcards so learners can see the conjugation in-place.
struct StemChangeConjugationView: View {
let infinitive: String
@Environment(\.modelContext) private var modelContext
@State private var rows: [ConjugationRow] = []
private static let personLabels = ["yo", "", "él/ella/Ud.", "nosotros", "vosotros", "ellos/ellas/Uds."]
private static let tenseId = "ind_presente"
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Present tense")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
}
if rows.isEmpty {
Text("Conjugation not available")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.vertical, 4)
} else {
VStack(spacing: 6) {
ForEach(rows) { row in
HStack(alignment: .firstTextBaseline) {
Text(row.person)
.font(.callout)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .leading)
IrregularHighlightText(
form: row.form,
spans: row.spans,
font: .callout.monospaced(),
showLabels: false
)
Spacer()
}
}
}
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.blue.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
.onAppear(perform: loadForms)
}
private func loadForms() {
// Find the verb by infinitive (lowercase exact match).
let normalized = infinitive.lowercased().trimmingCharacters(in: .whitespaces)
let verbDescriptor = FetchDescriptor<Verb>(
predicate: #Predicate<Verb> { $0.infinitive == normalized }
)
guard let verb = (try? modelContext.fetch(verbDescriptor))?.first else {
rows = []
return
}
let verbId = verb.id
let tenseId = Self.tenseId
let formDescriptor = FetchDescriptor<VerbForm>(
predicate: #Predicate<VerbForm> { $0.verbId == verbId && $0.tenseId == tenseId },
sortBy: [SortDescriptor(\VerbForm.personIndex)]
)
let forms = (try? modelContext.fetch(formDescriptor)) ?? []
rows = forms.map { f in
ConjugationRow(
id: f.personIndex,
person: Self.personLabels[safe: f.personIndex] ?? "",
form: f.form,
spans: f.spans ?? []
)
}
}
}
private struct ConjugationRow: Identifiable {
let id: Int
let person: String
let form: String
let spans: [IrregularSpan]
}
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
@@ -0,0 +1,121 @@
import SwiftUI
import SharedModels
import SwiftData
struct TextbookChapterListView: View {
let courseName: String
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Query(sort: \TextbookChapter.number) private var allChapters: [TextbookChapter]
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var attempts: [TextbookExerciseAttempt] = []
private var chapters: [TextbookChapter] {
allChapters.filter { $0.courseName == courseName }
}
private var byPart: [(part: Int, chapters: [TextbookChapter])] {
let grouped = Dictionary(grouping: chapters, by: \.part)
return grouped.keys.sorted().map { p in
(p, grouped[p]!.sorted { $0.number < $1.number })
}
}
private func progressFor(_ chapter: TextbookChapter) -> (correct: Int, total: Int) {
let chNum = chapter.number
let chAttempts = attempts.filter {
$0.courseName == courseName && $0.chapterNumber == chNum
}
let total = chAttempts.reduce(0) { $0 + $1.totalCount }
let correct = chAttempts.reduce(0) { $0 + $1.correctCount + $1.closeCount }
return (correct, total)
}
var body: some View {
List {
if chapters.isEmpty {
ContentUnavailableView(
"Textbook loading",
systemImage: "book.closed",
description: Text("Textbook content is being prepared…")
)
} else {
ForEach(byPart, id: \.part) { part, partChapters in
Section {
ForEach(partChapters, id: \.id) { chapter in
NavigationLink(value: chapter) {
chapterRow(chapter)
}
.accessibilityIdentifier("textbook-chapter-row-\(chapter.number)")
}
} header: {
if part > 0 {
Text("Part \(part)")
} else {
Text("Chapters")
}
}
}
}
}
.navigationTitle("Textbook")
.onAppear(perform: loadAttempts)
}
@ViewBuilder
private func chapterRow(_ chapter: TextbookChapter) -> some View {
let p = progressFor(chapter)
HStack(alignment: .center, spacing: 12) {
ZStack {
Circle()
.stroke(Color.secondary.opacity(0.2), lineWidth: 3)
.frame(width: 36, height: 36)
if p.total > 0 {
Circle()
.trim(from: 0, to: CGFloat(p.correct) / CGFloat(p.total))
.stroke(.orange, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.frame(width: 36, height: 36)
.rotationEffect(.degrees(-90))
}
Text("\(chapter.number)")
.font(.footnote.weight(.bold))
}
VStack(alignment: .leading, spacing: 2) {
Text(chapter.title)
.font(.headline)
HStack(spacing: 10) {
if chapter.exerciseCount > 0 {
Label("\(chapter.exerciseCount)", systemImage: "pencil.and.list.clipboard")
.font(.caption)
.foregroundStyle(.secondary)
}
if chapter.vocabTableCount > 0 {
Label("\(chapter.vocabTableCount)", systemImage: "list.bullet.rectangle")
.font(.caption)
.foregroundStyle(.secondary)
}
if p.total > 0 {
Text("\(p.correct)/\(p.total)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
Spacer()
}
.padding(.vertical, 4)
}
private func loadAttempts() {
attempts = (try? cloudModelContext.fetch(FetchDescriptor<TextbookExerciseAttempt>())) ?? []
}
}
#Preview {
NavigationStack {
TextbookChapterListView(courseName: "Complete Spanish Step-by-Step")
}
.modelContainer(for: [TextbookChapter.self], inMemory: true)
}
@@ -0,0 +1,209 @@
import SwiftUI
import SharedModels
import SwiftData
struct TextbookChapterView: View {
let chapter: TextbookChapter
@State private var expandedVocab: Set<Int> = []
private var blocks: [TextbookBlock] { chapter.blocks() }
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
headerView
Divider()
ForEach(blocks) { block in
blockView(block)
}
}
.padding(.horizontal)
.padding(.vertical, 12)
}
.navigationTitle(chapter.title)
.navigationBarTitleDisplayMode(.inline)
}
private var headerView: some View {
VStack(alignment: .leading, spacing: 4) {
if chapter.part > 0 {
Text("Part \(chapter.part)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Text("Chapter \(chapter.number)")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(chapter.title)
.font(.largeTitle.bold())
}
}
@ViewBuilder
private func blockView(_ block: TextbookBlock) -> some View {
switch block.kind {
case .heading:
headingView(block)
case .paragraph:
paragraphView(block)
case .keyVocabHeader:
HStack(spacing: 6) {
Image(systemName: "star.fill").foregroundStyle(.orange)
Text("Key Vocabulary")
.font(.headline)
.foregroundStyle(.orange)
}
.padding(.top, 8)
case .vocabTable:
vocabTableView(block)
case .exercise:
exerciseLinkView(block)
}
}
private func headingView(_ block: TextbookBlock) -> some View {
let level = block.level ?? 3
let font: Font
switch level {
case 2: font = .title.bold()
case 3: font = .title2.bold()
case 4: font = .title3.weight(.semibold)
default: font = .headline
}
return Text(stripInlineEmphasis(block.text ?? ""))
.font(font)
.padding(.top, 10)
}
private func paragraphView(_ block: TextbookBlock) -> some View {
Text(attributedFromMarkdownish(block.text ?? ""))
.font(.body)
.fixedSize(horizontal: false, vertical: true)
}
private func vocabTableView(_ block: TextbookBlock) -> some View {
let expanded = expandedVocab.contains(block.index)
let cards = block.cards ?? []
let lines = block.ocrLines ?? []
let itemCount = cards.isEmpty ? lines.count : cards.count
return VStack(alignment: .leading, spacing: 4) {
Button {
if expanded { expandedVocab.remove(block.index) } else { expandedVocab.insert(block.index) }
} label: {
HStack {
Image(systemName: expanded ? "chevron.down" : "chevron.right")
.font(.caption)
Text("Vocabulary (\(itemCount) items)")
.font(.subheadline.weight(.medium))
.foregroundStyle(.primary)
Spacer()
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if expanded {
if cards.isEmpty {
// Fallback: no paired cards available show raw OCR lines.
VStack(alignment: .leading, spacing: 2) {
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text(line)
.font(.callout.monospaced())
.foregroundStyle(.secondary)
}
}
.padding(.leading, 14)
} else {
vocabGrid(cards: cards)
.padding(.leading, 14)
}
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
}
@ViewBuilder
private func vocabGrid(cards: [TextbookVocabPair]) -> some View {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
ForEach(Array(cards.enumerated()), id: \.offset) { _, card in
GridRow {
Text(card.front)
.font(.callout)
.foregroundStyle(.primary)
Text(card.back)
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
}
private func exerciseLinkView(_ block: TextbookBlock) -> some View {
NavigationLink(value: TextbookExerciseDestination(
chapterId: chapter.id,
chapterNumber: chapter.number,
blockIndex: block.index
)) {
HStack(spacing: 10) {
Image(systemName: "pencil.and.list.clipboard")
.foregroundStyle(.orange)
.font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text("Exercise \(block.exerciseId ?? "")")
.font(.headline)
if let inst = block.instruction, !inst.isEmpty {
Text(stripInlineEmphasis(inst))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
.font(.caption)
}
.padding(12)
.background(Color.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
}
// Strip our ad-hoc ** / * markers from parsed text
private func stripInlineEmphasis(_ s: String) -> String {
s.replacingOccurrences(of: "**", with: "")
.replacingOccurrences(of: "*", with: "")
}
private func attributedFromMarkdownish(_ s: String) -> AttributedString {
// Parser emits `**bold**` and `*italic*`. Try to render via AttributedString markdown.
if let parsed = try? AttributedString(markdown: s, options: .init(allowsExtendedAttributes: true)) {
return parsed
}
return AttributedString(stripInlineEmphasis(s))
}
}
struct TextbookExerciseDestination: Hashable {
let chapterId: String
let chapterNumber: Int
let blockIndex: Int
}
#Preview {
NavigationStack {
TextbookChapterView(chapter: TextbookChapter(
id: "ch1",
number: 1,
title: "Sample",
part: 1,
courseName: "Preview",
bodyJSON: Data(),
exerciseCount: 0,
vocabTableCount: 0
))
}
}
@@ -0,0 +1,361 @@
import SwiftUI
import SharedModels
import SwiftData
import PencilKit
/// Interactive fill-in-the-blank view for one textbook exercise.
/// Supports keyboard typing OR Apple Pencil handwriting input per prompt.
struct TextbookExerciseView: View {
let chapter: TextbookChapter
let blockIndex: Int
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var answers: [Int: String] = [:]
@State private var drawings: [Int: PKDrawing] = [:]
@State private var grades: [Int: TextbookGrade] = [:]
@State private var inputMode: InputMode = .keyboard
@State private var activePencilPromptNumber: Int?
@State private var isRecognizing = false
@State private var isChecked = false
@State private var recognizedTextForActive: String = ""
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
enum InputMode: String {
case keyboard
case pencil
}
private var block: TextbookBlock? {
chapter.blocks().first { $0.index == blockIndex }
}
private var answerByNumber: [Int: TextbookAnswerItem] {
guard let items = block?.answerItems else { return [:] }
var out: [Int: TextbookAnswerItem] = [:]
for it in items {
out[it.number] = it
}
return out
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let b = block {
headerView(b)
inputModePicker
exerciseBody(b)
checkButton(b)
} else {
ContentUnavailableView(
"Exercise not found",
systemImage: "questionmark.circle"
)
}
}
.padding()
}
.navigationTitle("Exercise \(block?.exerciseId ?? "")")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadPreviousAttempt)
}
private func headerView(_ b: TextbookBlock) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text("Chapter \(chapter.number): \(chapter.title)")
.font(.caption)
.foregroundStyle(.secondary)
Text("Exercise \(b.exerciseId ?? "")")
.font(.title2.bold())
if let inst = b.instruction, !inst.isEmpty {
Text(stripInlineEmphasis(inst))
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let extra = b.extra, !extra.isEmpty {
ForEach(Array(extra.enumerated()), id: \.offset) { _, e in
Text(stripInlineEmphasis(e))
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
}
}
}
}
private var inputModePicker: some View {
Picker("Input mode", selection: $inputMode) {
Label("Keyboard", systemImage: "keyboard").tag(InputMode.keyboard)
Label("Pencil", systemImage: "pencil.tip").tag(InputMode.pencil)
}
.pickerStyle(.segmented)
}
private func exerciseBody(_ b: TextbookBlock) -> some View {
VStack(alignment: .leading, spacing: 14) {
if b.freeform == true {
VStack(alignment: .leading, spacing: 6) {
Label("Freeform exercise", systemImage: "text.bubble")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
Text("Answers will vary. Use this space to write your own responses; they won't be auto-checked.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.background(Color.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
}
let rawPrompts = b.prompts ?? []
let prompts = rawPrompts.isEmpty ? synthesizedPrompts(b) : rawPrompts
if prompts.isEmpty && b.extra?.isEmpty == false {
Text("Fill in the blanks above; answers will be graded when you tap Check.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(Array(prompts.enumerated()), id: \.offset) { i, prompt in
promptRow(index: i, prompt: prompt, expected: answerByNumber[i + 1])
}
}
}
}
/// When the source exercise prompts were embedded in a bitmap (common in
/// this textbook), we have no text for each question only the answer
/// key. Synthesize numbered placeholders so the user still gets one input
/// field per answer.
private func synthesizedPrompts(_ b: TextbookBlock) -> [String] {
guard let items = b.answerItems, !items.isEmpty else { return [] }
return items.map { "\($0.number)." }
}
private func promptRow(index: Int, prompt: String, expected: TextbookAnswerItem?) -> some View {
let number = index + 1
let grade = grades[number]
return VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 8) {
if let grade {
Image(systemName: iconFor(grade))
.foregroundStyle(colorFor(grade))
.font(.title3)
.padding(.top, 2)
}
Text(stripInlineEmphasis(prompt))
.font(.body)
.fixedSize(horizontal: false, vertical: true)
}
switch inputMode {
case .keyboard:
TextField("Your answer", text: binding(for: number))
.textFieldStyle(.roundedBorder)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.font(.body)
.disabled(isChecked)
case .pencil:
pencilRow(number: number)
}
if isChecked, let grade, grade != .correct, let expected {
HStack(spacing: 6) {
Text("Answer:")
.font(.caption.weight(.semibold))
Text(expected.answer)
.font(.caption)
if !expected.alternates.isEmpty {
Text("(also: \(expected.alternates.joined(separator: ", ")))")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.foregroundStyle(colorFor(grade))
}
}
.padding(10)
.background(backgroundFor(grade), in: RoundedRectangle(cornerRadius: 8))
}
private func pencilRow(number: Int) -> some View {
VStack(alignment: .leading, spacing: 6) {
HandwritingCanvas(
drawing: bindingDrawing(for: number),
onDrawingChanged: { recognizePencil(for: number) }
)
.frame(height: 100)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.separator, lineWidth: 1))
HStack {
if let typed = answers[number], !typed.isEmpty {
Text("Recognized: \(typed)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Clear") {
drawings[number] = PKDrawing()
answers[number] = ""
}
.font(.caption)
.tint(.secondary)
}
}
}
private func checkButton(_ b: TextbookBlock) -> some View {
let hasAnyAnswer = answers.values.contains { !$0.isEmpty }
let disabled = b.freeform == true || (!isChecked && !hasAnyAnswer)
return Button {
if isChecked {
resetExercise()
} else {
checkAnswers(b)
}
} label: {
Text(isChecked ? "Try again" : "Check answers")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
.buttonStyle(.borderedProminent)
.tint(.orange)
.disabled(disabled)
}
// MARK: - Actions
private func checkAnswers(_ b: TextbookBlock) {
guard let prompts = b.prompts else { return }
var newGrades: [Int: TextbookGrade] = [:]
var states: [TextbookPromptState] = []
for (i, _) in prompts.enumerated() {
let number = i + 1
let user = answers[number] ?? ""
let expected = answerByNumber[number]
let canonical = expected?.answer ?? ""
let alts = expected?.alternates ?? []
let grade: TextbookGrade
if canonical.isEmpty {
grade = .wrong
} else {
grade = AnswerChecker.grade(userText: user, canonical: canonical, alternates: alts)
}
newGrades[number] = grade
states.append(TextbookPromptState(number: number, userText: user, grade: grade))
}
grades = newGrades
isChecked = true
ReviewStore.recordActivity(context: cloudModelContext)
saveAttempt(states: states, exerciseId: b.exerciseId ?? "")
}
private func resetExercise() {
answers.removeAll()
drawings.removeAll()
grades.removeAll()
isChecked = false
}
private func recognizePencil(for number: Int) {
guard let drawing = drawings[number], !drawing.strokes.isEmpty else { return }
isRecognizing = true
Task {
let result = await HandwritingRecognizer.recognize(drawing: drawing)
await MainActor.run {
answers[number] = result.text
isRecognizing = false
}
}
}
private func saveAttempt(states: [TextbookPromptState], exerciseId: String) {
let attemptId = TextbookExerciseAttempt.attemptId(
courseName: chapter.courseName,
exerciseId: exerciseId
)
let descriptor = FetchDescriptor<TextbookExerciseAttempt>(
predicate: #Predicate<TextbookExerciseAttempt> { $0.id == attemptId }
)
let context = cloudModelContext
let existing = (try? context.fetch(descriptor))?.first
let attempt = existing ?? TextbookExerciseAttempt(
id: attemptId,
courseName: chapter.courseName,
chapterNumber: chapter.number,
exerciseId: exerciseId
)
if existing == nil { context.insert(attempt) }
attempt.lastAttemptAt = Date()
attempt.setPromptStates(states)
try? context.save()
}
private func loadPreviousAttempt() {
guard let b = block else { return }
let attemptId = TextbookExerciseAttempt.attemptId(
courseName: chapter.courseName,
exerciseId: b.exerciseId ?? ""
)
let descriptor = FetchDescriptor<TextbookExerciseAttempt>(
predicate: #Predicate<TextbookExerciseAttempt> { $0.id == attemptId }
)
guard let attempt = (try? cloudModelContext.fetch(descriptor))?.first else { return }
for s in attempt.promptStates() {
answers[s.number] = s.userText
grades[s.number] = s.grade
}
isChecked = !grades.isEmpty
}
// MARK: - Bindings
private func binding(for number: Int) -> Binding<String> {
Binding(
get: { answers[number] ?? "" },
set: { answers[number] = $0 }
)
}
private func bindingDrawing(for number: Int) -> Binding<PKDrawing> {
Binding(
get: { drawings[number] ?? PKDrawing() },
set: { drawings[number] = $0 }
)
}
// MARK: - UI helpers
private func iconFor(_ grade: TextbookGrade) -> String {
switch grade {
case .correct: return "checkmark.circle.fill"
case .close: return "circle.lefthalf.filled"
case .wrong: return "xmark.circle.fill"
}
}
private func colorFor(_ grade: TextbookGrade) -> Color {
switch grade {
case .correct: return .green
case .close: return .orange
case .wrong: return .red
}
}
private func backgroundFor(_ grade: TextbookGrade?) -> Color {
guard let grade else { return Color.secondary.opacity(0.05) }
switch grade {
case .correct: return .green.opacity(0.12)
case .close: return .orange.opacity(0.12)
case .wrong: return .red.opacity(0.12)
}
}
private func stripInlineEmphasis(_ s: String) -> String {
s.replacingOccurrences(of: "**", with: "")
.replacingOccurrences(of: "*", with: "")
}
}
@@ -6,11 +6,23 @@ struct VocabFlashcardView: View {
let cards: [VocabCard] let cards: [VocabCard]
let speechService: SpeechService let speechService: SpeechService
let onDone: () -> Void let onDone: () -> Void
/// Optional deck context when present and the title indicates a stem-
/// changing deck, each card gets an inline conjugation toggle.
var deckTitle: String? = nil
/// When set, a star button appears next to the speaker on reveal so the
/// user can mark the card for extra study.
var markContext: ExtraStudyMarkContext? = nil
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var currentIndex = 0 @State private var currentIndex = 0
@State private var isRevealed = false @State private var isRevealed = false
@State private var sessionCorrect = 0 @State private var sessionCorrect = 0
@State private var showConjugation = false
@State private var markedIds: Set<String> = []
private var isStemChangingDeck: Bool {
(deckTitle ?? "").localizedCaseInsensitiveContains("stem changing")
}
private var cloudModelContext: ModelContext { cloudModelContextProvider() } private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@@ -53,14 +65,48 @@ struct VocabFlashcardView: View {
.font(.title.weight(.medium)) .font(.title.weight(.medium))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Button { HStack(spacing: 12) {
speechService.speak(card.front) Button {
} label: { speechService.speak(card.front)
Image(systemName: "speaker.wave.2.fill") } label: {
.font(.title3) Image(systemName: "speaker.wave.2.fill")
.padding(12) .font(.title3)
.padding(12)
}
.glassEffect(in: .circle)
if markContext != nil {
Button {
toggleMark()
} label: {
Image(systemName: currentIsMarked ? "star.fill" : "star")
.font(.title3)
.padding(12)
.foregroundStyle(currentIsMarked ? .yellow : .secondary)
}
.glassEffect(in: .circle)
.accessibilityLabel(currentIsMarked ? "Unmark for extra study" : "Mark for extra study")
}
}
if isStemChangingDeck {
Button {
withAnimation(.smooth) { showConjugation.toggle() }
} label: {
Label(
showConjugation ? "Hide conjugation" : "Show conjugation",
systemImage: showConjugation ? "chevron.up" : "chevron.down"
)
.font(.subheadline.weight(.medium))
}
.buttonStyle(.bordered)
.tint(.blue)
if showConjugation {
StemChangeConjugationView(infinitive: stripToInfinitive(card.front))
.transition(.opacity.combined(with: .move(edge: .top)))
}
} }
.glassEffect(in: .circle)
} }
.transition(.blurReplace) .transition(.blurReplace)
} else { } else {
@@ -111,6 +157,7 @@ struct VocabFlashcardView: View {
guard currentIndex > 0 else { return } guard currentIndex > 0 else { return }
withAnimation(.smooth) { withAnimation(.smooth) {
isRevealed = false isRevealed = false
showConjugation = false
currentIndex -= 1 currentIndex -= 1
} }
} label: { } label: {
@@ -125,6 +172,7 @@ struct VocabFlashcardView: View {
Button { Button {
withAnimation(.smooth) { withAnimation(.smooth) {
isRevealed = false isRevealed = false
showConjugation = false
currentIndex += 1 currentIndex += 1
} }
} label: { } label: {
@@ -165,6 +213,34 @@ struct VocabFlashcardView: View {
} }
.animation(.smooth, value: isRevealed) .animation(.smooth, value: isRevealed)
.animation(.smooth, value: currentIndex) .animation(.smooth, value: currentIndex)
.task { loadMarks() }
}
private var currentIsMarked: Bool {
guard let card = currentCard else { return false }
return markedIds.contains(CourseCardStore.reviewKey(for: card))
}
private func loadMarks() {
guard let ctx = markContext else { return }
markedIds = ExtraStudyStore(context: cloudModelContext)
.fetchIds(courseName: ctx.courseName, weekNumber: ctx.weekNumber)
}
private func toggleMark() {
guard let card = currentCard, let ctx = markContext else { return }
let store = ExtraStudyStore(context: cloudModelContext)
let isNowMarked = store.toggle(
card: card,
courseName: ctx.courseName,
weekNumber: ctx.weekNumber
)
let key = CourseCardStore.reviewKey(for: card)
if isNowMarked {
markedIds.insert(key)
} else {
markedIds.remove(key)
}
} }
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View { private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
@@ -189,9 +265,25 @@ struct VocabFlashcardView: View {
// Next card // Next card
withAnimation(.smooth) { withAnimation(.smooth) {
isRevealed = false isRevealed = false
showConjugation = false
currentIndex += 1 currentIndex += 1
} }
} }
/// Card fronts may be plain infinitives ("cerrar") or, in reversed decks,
/// stored as English. Strip any reflexive-se suffix or parenthetical notes
/// to improve the verb lookup hit rate.
private func stripToInfinitive(_ s: String) -> String {
var t = s.trimmingCharacters(in: .whitespacesAndNewlines)
if let paren = t.firstIndex(of: "(") {
t = String(t[..<paren]).trimmingCharacters(in: .whitespacesAndNewlines)
}
if t.hasSuffix("se") && t.count > 4 {
// "acostarse" "acostar" for verb lookup
t = String(t.dropLast(2))
}
return t
}
} }
#Preview { #Preview {
@@ -288,7 +288,10 @@ struct DashboardView: View {
} }
private func loadData() { private func loadData() {
userProgress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext) let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
// Reset a stale streak before rendering so the dashboard never lies.
progress.validateStreakIfStale(context: cloudModelContext)
userProgress = progress
let dailyDescriptor = FetchDescriptor<DailyLog>( let dailyDescriptor = FetchDescriptor<DailyLog>(
sortBy: [SortDescriptor(\DailyLog.dateString, order: .reverse)] sortBy: [SortDescriptor(\DailyLog.dateString, order: .reverse)]
) )
@@ -1,9 +1,12 @@
import SwiftUI import SwiftUI
import SwiftData
struct GrammarExerciseView: View { struct GrammarExerciseView: View {
let noteId: String let noteId: String
let noteTitle: String let noteTitle: String
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var exercises: [GrammarExercise] = [] @State private var exercises: [GrammarExercise] = []
@State private var currentIndex = 0 @State private var currentIndex = 0
@@ -96,6 +99,7 @@ struct GrammarExerciseView: View {
currentIndex += 1 currentIndex += 1
selectedOption = nil selectedOption = nil
} else { } else {
ReviewStore.recordActivity(context: cloudModelContext)
withAnimation { isFinished = true } withAnimation { isFinished = true }
} }
} label: { } label: {
@@ -1,4 +1,6 @@
import SwiftUI import SwiftUI
import SwiftData
import SharedModels
/// Standalone grammar notes view with its own NavigationStack (used outside GuideView). /// Standalone grammar notes view with its own NavigationStack (used outside GuideView).
struct GrammarNotesView: View { struct GrammarNotesView: View {
@@ -19,9 +21,9 @@ struct GrammarNotesListView: View {
@Binding var selectedNote: GrammarNote? @Binding var selectedNote: GrammarNote?
private var groupedNotes: [(String, [GrammarNote])] { private var groupedNotes: [(String, [GrammarNote])] {
let grouped = Dictionary(grouping: GrammarNote.allNotes, by: \.category) let grouped = Dictionary(grouping: GrammarNote.allNotesIncludingGenerated, by: \.category)
var seen: [String] = [] var seen: [String] = []
for note in GrammarNote.allNotes { for note in GrammarNote.allNotesIncludingGenerated {
if !seen.contains(note.category) { if !seen.contains(note.category) {
seen.append(note.category) seen.append(note.category)
} }
@@ -67,6 +69,24 @@ private struct GrammarNoteRow: View {
struct GrammarNoteDetailView: View { struct GrammarNoteDetailView: View {
let note: GrammarNote let note: GrammarNote
var onJumpToTense: ((TenseGuide) -> Void)? = nil
@Environment(YouTubeVideoStore.self) private var videoStore
@State private var relatedTenses: [TenseGuide] = []
private var curatedVideo: YouTubeVideoStore.VideoEntry? {
videoStore.video(forGrammarNoteId: note.id)
}
private func loadRelatedTenses() {
guard let container = SharedStore.localContainer else {
relatedTenses = []
return
}
let context = ModelContext(container)
let guides = ReferenceStore(context: context).fetchGuides()
let byId = Dictionary(uniqueKeysWithValues: guides.map { ($0.tenseId, $0) })
relatedTenses = GuideCrossLinks.tenseIds(forNote: note.id).compactMap { byId[$0] }
}
var body: some View { var body: some View {
ScrollView { ScrollView {
@@ -83,6 +103,12 @@ struct GrammarNoteDetailView: View {
.background(.fill.tertiary, in: Capsule()) .background(.fill.tertiary, in: Capsule())
} }
if !relatedTenses.isEmpty {
relatedTensesSection
}
videoSection
Divider() Divider()
// Parsed body // Parsed body
@@ -107,6 +133,49 @@ struct GrammarNoteDetailView: View {
} }
.navigationTitle(note.title) .navigationTitle(note.title)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadRelatedTenses)
.onChange(of: note.id) { _, _ in loadRelatedTenses() }
}
private var relatedTensesSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Used in tenses", systemImage: "clock.arrow.circlepath")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(relatedTenses, id: \.tenseId) { guide in
Button {
onJumpToTense?(guide)
} label: {
Text(TenseInfo.find(guide.tenseId)?.english ?? guide.title)
.font(.caption.weight(.semibold))
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(.orange.opacity(0.12), in: Capsule())
.foregroundStyle(.orange)
}
.buttonStyle(.plain)
}
}
}
}
}
@ViewBuilder
private var videoSection: some View {
if let video = curatedVideo {
VideoActionsButtonRow(video: video)
} else {
Label("No video yet", systemImage: "play.slash")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(.fill.quinary, in: Capsule())
}
} }
} }
+96 -7
View File
@@ -41,15 +41,20 @@ struct GuideView: View {
.navigationTitle("Guide") .navigationTitle("Guide")
.task { loadGuides() } .task { loadGuides() }
.onAppear(perform: loadGuides) .onAppear(perform: loadGuides)
.onChange(of: selectedTab) { _, _ in .onChange(of: selectedTab) { _, newTab in
selectedGuide = nil // Only clear the *other* tab's selection so programmatic
selectedNote = nil // cross-link jumps (chip taps in the detail pane) can keep
// their newly-set selection on the destination tab.
switch newTab {
case .tenses: selectedNote = nil
case .grammar: selectedGuide = nil
}
} }
} detail: { } detail: {
if let guide = selectedGuide { if let guide = selectedGuide {
GuideDetailView(guide: guide) GuideDetailView(guide: guide, onJumpToNote: jumpToNote)
} else if let note = selectedNote { } else if let note = selectedNote {
GrammarNoteDetailView(note: note) GrammarNoteDetailView(note: note, onJumpToTense: jumpToTense)
} else { } else {
ContentUnavailableView("Select a Topic", systemImage: "book", description: Text("Choose a tense or grammar topic to learn more.")) ContentUnavailableView("Select a Topic", systemImage: "book", description: Text("Choose a tense or grammar topic to learn more."))
} }
@@ -79,6 +84,16 @@ struct GuideView: View {
GrammarNotesListView(selectedNote: $selectedNote) GrammarNotesListView(selectedNote: $selectedNote)
} }
private func jumpToNote(_ note: GrammarNote) {
selectedTab = .grammar
selectedNote = note
}
private func jumpToTense(_ guide: TenseGuide) {
selectedTab = .tenses
selectedGuide = guide
}
private func loadGuides() { private func loadGuides() {
// Hit the shared local container directly, bypassing @Environment. // Hit the shared local container directly, bypassing @Environment.
guard let container = SharedStore.localContainer else { guard let container = SharedStore.localContainer else {
@@ -127,11 +142,23 @@ private struct TenseRowView: View {
struct GuideDetailView: View { struct GuideDetailView: View {
let guide: TenseGuide let guide: TenseGuide
var onJumpToNote: ((GrammarNote) -> Void)? = nil
@Environment(YouTubeVideoStore.self) private var videoStore
private var relatedNotes: [GrammarNote] {
GuideCrossLinks.noteIds(forTense: guide.tenseId).compactMap { id in
GrammarNote.allNotesIncludingGenerated.first { $0.id == id }
}
}
private var tenseInfo: TenseInfo? { private var tenseInfo: TenseInfo? {
TenseInfo.find(guide.tenseId) TenseInfo.find(guide.tenseId)
} }
private var curatedVideo: YouTubeVideoStore.VideoEntry? {
videoStore.video(forTenseId: guide.tenseId)
}
private var endingTable: TenseEndingTable? { private var endingTable: TenseEndingTable? {
TenseEndingTable.find(guide.tenseId) TenseEndingTable.find(guide.tenseId)
} }
@@ -146,6 +173,14 @@ struct GuideDetailView: View {
// Header // Header
headerSection headerSection
// Related grammar notes cross-links into the Grammar tab
if !relatedNotes.isEmpty {
relatedNotesSection
}
// Video section (Issue #21)
videoSection
// Conjugation ending table // Conjugation ending table
if let table = endingTable { if let table = endingTable {
conjugationTableSection(table) conjugationTableSection(table)
@@ -180,6 +215,51 @@ struct GuideDetailView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} }
// MARK: - Related grammar notes
private var relatedNotesSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Related grammar", systemImage: "book")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(relatedNotes, id: \.id) { note in
Button {
onJumpToNote?(note)
} label: {
Text(note.title)
.font(.caption.weight(.semibold))
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(.indigo.opacity(0.12), in: Capsule())
.foregroundStyle(.indigo)
}
.buttonStyle(.plain)
}
}
}
}
}
// MARK: - Video (Issue #21)
@ViewBuilder
private var videoSection: some View {
if let video = curatedVideo {
VideoActionsButtonRow(video: video)
} else {
Label("No video yet", systemImage: "play.slash")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(.fill.quinary, in: Capsule())
}
}
// MARK: - Header // MARK: - Header
private var headerSection: some View { private var headerSection: some View {
@@ -450,14 +530,17 @@ struct GuideContent {
var spanishLine: String? var spanishLine: String?
func flushUsage() { func flushUsage() {
if currentUsageNumber > 0 { // 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( usages.append(GuideUsage(
number: currentUsageNumber, number: currentUsageNumber,
title: currentUsageTitle, title: currentUsageTitle,
examples: currentExamples examples: currentExamples
)) ))
currentExamples = []
} }
currentExamples = []
} }
for line in lines { for line in lines {
@@ -491,6 +574,11 @@ struct GuideContent {
let title = String(match.1).replacingOccurrences(of: "*", with: "") let title = String(match.1).replacingOccurrences(of: "*", with: "")
if title.lowercased().contains("usage") { if title.lowercased().contains("usage") {
inUsages = true 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 continue
} }
} }
@@ -563,4 +651,5 @@ struct GuideExample: Identifiable {
#Preview { #Preview {
GuideView() GuideView()
.modelContainer(for: TenseGuide.self, inMemory: true) .modelContainer(for: TenseGuide.self, inMemory: true)
.environment(YouTubeVideoStore())
} }
@@ -0,0 +1,245 @@
import SwiftUI
import SwiftData
import AVKit
import SharedModels
/// Three-button row for a curated YouTube video (Issue #21):
/// - **Stream** opens in the YouTube app (falls back to Safari).
/// - **Download** pulls the MP4 via YouTubeKit, shows progress, then enables Play.
/// - **Play** enabled only when the video exists on disk; plays via AVPlayer.
///
/// Used by both `GuideDetailView` and `GrammarNoteDetailView` to keep the
/// video affordances consistent.
struct VideoActionsButtonRow: View {
let video: YouTubeVideoStore.VideoEntry
@Environment(\.openURL) private var openURL
@Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var downloadService = VideoDownloadService.shared
@State private var isDownloaded: Bool
@State private var playerVideoId: String?
@State private var downloadError: String?
@State private var confirmDelete: Bool = false
init(video: YouTubeVideoStore.VideoEntry) {
self.video = video
self._isDownloaded = State(initialValue: VideoDownloadService.isDownloaded(videoId: video.videoId))
}
private var activeStatus: VideoDownloadService.DownloadStatus? {
downloadService.activeDownloads[video.videoId]
}
private var isDownloading: Bool {
activeStatus != nil
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(video.title)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
HStack(spacing: 10) {
streamButton
downloadButton
playButton
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
.fullScreenCover(item: Binding(
get: { playerVideoId.map { LocalVideoID(videoId: $0) } },
set: { playerVideoId = $0?.videoId }
)) { id in
LocalVideoPlayerSheet(videoId: id.videoId, title: video.title)
}
.alert("Download failed", isPresented: .init(
get: { downloadError != nil },
set: { if !$0 { downloadError = nil } }
)) {
Button("OK") { downloadError = nil }
} message: {
Text(downloadError ?? "")
}
.confirmationDialog(
"Delete this downloaded video?",
isPresented: $confirmDelete,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
downloadService.delete(videoId: video.videoId, modelContext: modelContext)
isDownloaded = false
}
Button("Cancel", role: .cancel) {}
} message: {
Text("You can re-download it at any time.")
}
.onAppear {
// Refresh on appear in case the user deleted the file via Settings.
isDownloaded = VideoDownloadService.isDownloaded(videoId: video.videoId)
}
}
// MARK: - Buttons
private var streamButton: some View {
Button {
if let url = URL(string: "https://www.youtube.com/watch?v=\(video.videoId)") {
ReviewStore.recordActivity(context: cloudModelContext)
openURL(url)
}
} label: {
Label("Stream", systemImage: "play.rectangle.fill")
.labelStyle(VideoActionLabelStyle())
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.red)
.controlSize(.large)
}
@ViewBuilder
private var downloadButton: some View {
// Single slot whose role flips through the download lifecycle:
// Download progress/label (disabled) Delete.
if let status = activeStatus {
Button {} label: {
VStack(spacing: 4) {
if let progress = status.progress {
ProgressView(value: progress).frame(width: 56)
} else {
ProgressView().controlSize(.small)
}
Text(status.label)
.font(.caption2.monospacedDigit().weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.blue)
.controlSize(.large)
.disabled(true)
} else if isDownloaded {
Button(role: .destructive) {
confirmDelete = true
} label: {
Label("Delete", systemImage: "trash")
.labelStyle(VideoActionLabelStyle())
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.red)
.controlSize(.large)
} else {
Button {
Task { await startDownload() }
} label: {
Label("Download", systemImage: "arrow.down.to.line")
.labelStyle(VideoActionLabelStyle())
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.blue)
.controlSize(.large)
}
}
private var playButton: some View {
Button {
ReviewStore.recordActivity(context: cloudModelContext)
playerVideoId = video.videoId
} label: {
Label("Play", systemImage: "play.fill")
.labelStyle(VideoActionLabelStyle())
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.green)
.controlSize(.large)
.disabled(!isDownloaded)
}
// MARK: - Actions
private func startDownload() async {
do {
try await downloadService.download(
videoId: video.videoId,
title: video.title,
into: modelContext
)
isDownloaded = true
ReviewStore.recordActivity(context: cloudModelContext)
} catch {
downloadError = error.localizedDescription
}
}
}
// MARK: - Helper identifiable wrapper so .sheet(item:) can use a plain String
private struct LocalVideoID: Identifiable {
let videoId: String
var id: String { videoId }
}
// Stacks the icon above the title so three equal-width buttons fit an iPhone
// row without wrapping mid-word.
private struct VideoActionLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(spacing: 4) {
configuration.icon
.imageScale(.large)
configuration.title
.font(.caption2.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
}
}
// MARK: - Local playback sheet
struct LocalVideoPlayerSheet: View {
let videoId: String
let title: String
@Environment(\.dismiss) private var dismiss
@State private var player: AVPlayer
init(videoId: String, title: String) {
self.videoId = videoId
self.title = title
self._player = State(initialValue: AVPlayer(url: VideoDownloadService.fileURL(for: videoId)))
}
var body: some View {
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()
VideoPlayer(player: player)
.ignoresSafeArea()
.onAppear { player.play() }
.onDisappear { player.pause() }
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.black, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
}
}
@@ -128,7 +128,7 @@ struct OnboardingView: View {
private func completeOnboarding() { private func completeOnboarding() {
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext) let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
progress.selectedVerbLevel = selectedLevel progress.selectedVerbLevels = [selectedLevel]
if progress.enabledTenseIDs.isEmpty { if progress.enabledTenseIDs.isEmpty {
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses() progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
} }
@@ -0,0 +1,43 @@
import SwiftUI
import SharedModels
import SwiftData
struct BookChapterListView: View {
let book: Book
@Query private var allChapters: [BookChapter]
init(book: Book) {
self.book = book
let slug = book.slug
_allChapters = Query(
filter: #Predicate<BookChapter> { $0.bookSlug == slug },
sort: \BookChapter.number
)
}
var body: some View {
List {
ForEach(allChapters) { chapter in
NavigationLink(value: chapter) {
HStack(spacing: 12) {
Text("\(chapter.number)")
.font(.subheadline.weight(.bold).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 32, alignment: .trailing)
VStack(alignment: .leading, spacing: 2) {
Text(chapter.title)
.font(.subheadline.weight(.medium))
Text("\(chapter.paragraphCount) paragraph\(chapter.paragraphCount == 1 ? "" : "s")")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
}
}
.navigationTitle(book.title.prefix(while: { $0 != ":" }).description)
.navigationBarTitleDisplayMode(.inline)
}
}
@@ -0,0 +1,105 @@
import SwiftUI
import SharedModels
import SwiftData
/// Route value for pushing the books library. Lets `PracticeView` use a
/// value-based link so the entire books navigation chain is consistent
/// mixing a view-based push with value-based pushes deeper in the same
/// NavigationStack made pushed screens pop back immediately.
enum BooksRoute: Hashable {
case library
}
struct BookLibraryView: View {
@Query(sort: \Book.title) private var books: [Book]
var body: some View {
Group {
if books.isEmpty {
ContentUnavailableView(
"No Books",
systemImage: "books.vertical",
description: Text("Books bundled with the app will appear here.")
)
} else {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(books) { book in
NavigationLink(value: book) {
BookCard(book: book)
}
.tint(.primary)
}
}
.padding()
}
}
}
.navigationTitle("Books")
.navigationBarTitleDisplayMode(.inline)
}
}
private struct BookCard: View {
let book: Book
private var accentColor: Color {
Color(hex: book.accentColorHex) ?? .indigo
}
private var shortTitle: String {
// Trim "Volume X" subtitle if present most book titles are way too long.
if let colon = book.title.firstIndex(of: ":") {
return String(book.title[..<colon])
}
return book.title
}
var body: some View {
HStack(spacing: 14) {
RoundedRectangle(cornerRadius: 6)
.fill(accentColor.gradient)
.frame(width: 48, height: 64)
.overlay {
Image(systemName: "book.closed.fill")
.font(.title3)
.foregroundStyle(.white.opacity(0.9))
}
VStack(alignment: .leading, spacing: 4) {
Text(shortTitle)
.font(.subheadline.weight(.semibold))
.multilineTextAlignment(.leading)
if !book.author.isEmpty {
Text(book.author)
.font(.caption)
.foregroundStyle(.secondary)
}
Text("\(book.chapterCount) chapter\(book.chapterCount == 1 ? "" : "s")")
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
}
}
private extension Color {
init?(hex: String) {
var s = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if s.hasPrefix("#") { s.removeFirst() }
guard s.count == 6, let v = UInt32(s, radix: 16) else { return nil }
let r = Double((v >> 16) & 0xFF) / 255.0
let g = Double((v >> 8) & 0xFF) / 255.0
let b = Double(v & 0xFF) / 255.0
self = Color(red: r, green: g, blue: b)
}
}
@@ -0,0 +1,434 @@
import SwiftUI
import SharedModels
import SwiftData
import FoundationModels
struct BookReaderView: View {
let chapter: BookChapter
/// The book this chapter belongs to, resolved by slug used for the
/// pre-computed glossary. A @Query is safe here because the reader is
/// built lazily by `navigationDestination`: one instance, when opened.
@Query private var bookMatches: [Book]
private var book: Book? { bookMatches.first }
@Environment(DictionaryService.self) private var dictionary
@State private var speech = BookSpeechController()
@State private var selectedWord: WordAnnotation?
@State private var showEnglish = false
@State private var showVoicePicker = false
@State private var wasReadingBeforeTap = false
@State private var lookupCache: [String: WordAnnotation] = [:]
/// The book's pre-computed glossary, decoded once on appear.
@State private var glossary: [String: WordGloss] = [:]
@AppStorage("bookReaderVoiceId") private var storedVoiceId: String = ""
@AppStorage("bookReaderRate") private var storedRate: Double = 0.45
init(chapter: BookChapter) {
self.chapter = chapter
let slug = chapter.bookSlug
_bookMatches = Query(filter: #Predicate<Book> { $0.slug == slug })
}
private var paragraphsES: [String] { chapter.paragraphsES() }
private var paragraphsEN: [String] { chapter.paragraphsEN() }
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 18) {
Text(chapter.title)
.font(.title2.bold())
.padding(.bottom, 4)
.id(-1)
ForEach(Array(paragraphsES.enumerated()), id: \.offset) { index, paragraph in
paragraphView(index: index, paragraph: paragraph)
.id(index)
}
}
.padding()
.adaptiveContainer(maxWidth: 800)
}
.onChange(of: speech.currentParagraphIndex) { _, newIndex in
guard let newIndex else { return }
withAnimation(.easeInOut(duration: 0.25)) {
proxy.scrollTo(newIndex, anchor: .center)
}
}
}
.navigationTitle("Chapter \(chapter.number)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Button {
showVoicePicker = true
} label: {
Image(systemName: "waveform.circle")
.symbolRenderingMode(.hierarchical)
}
.accessibilityLabel("Voice & speed")
Button {
toggleReadAloud()
} label: {
Image(systemName: speech.isReading ? "stop.circle.fill" : "play.circle.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.indigo)
}
.accessibilityLabel(speech.isReading ? "Stop reading" : "Read aloud")
Button {
withAnimation { showEnglish.toggle() }
} label: {
Image(systemName: showEnglish ? "character.book.closed.fill.he" : "character.book.closed")
.symbolRenderingMode(.hierarchical)
}
.accessibilityLabel(showEnglish ? "Show Spanish" : "Show English")
}
}
.sheet(item: $selectedWord, onDismiss: handleSheetDismiss) { word in
WordDetailSheet(word: word)
.presentationDetents([.height(220)])
}
.sheet(isPresented: $showVoicePicker) {
BookVoicePickerSheet(voiceIdentifier: voiceBinding, rate: rateBinding)
}
.onAppear {
speech.voiceIdentifier = storedVoiceId.isEmpty ? nil : storedVoiceId
speech.rate = Float(storedRate)
if glossary.isEmpty {
glossary = book?.glossary() ?? [:]
}
}
.onDisappear {
speech.stop()
}
}
@ViewBuilder
private func paragraphView(index: Int, paragraph: String) -> some View {
if showEnglish {
Text(translation(for: index))
.font(.body)
.foregroundStyle(.secondary)
} else {
TappableParagraph(
text: paragraph,
highlightedWordIndex: speech.currentParagraphIndex == index ? speech.currentWordIndex : nil
) { word in
handleTap(word: word, paragraph: paragraph)
}
}
}
private func translation(for index: Int) -> String {
guard index < paragraphsEN.count else { return "" }
let en = paragraphsEN[index]
return en.isEmpty ? "[translation unavailable]" : en
}
// MARK: - Read-along controls
private func toggleReadAloud() {
if speech.isReading {
speech.stop()
} else {
// Start from the first non-vocab paragraph at or after the topmost
// visible one. For V1 we start from the chapter top adding
// "start from visible paragraph" would need a scroll-position
// observer, which isn't worth the complexity yet.
speech.start(paragraphs: paragraphsES)
}
}
private var voiceBinding: Binding<String?> {
Binding(
get: { storedVoiceId.isEmpty ? nil : storedVoiceId },
set: { newValue in
storedVoiceId = newValue ?? ""
speech.voiceIdentifier = newValue
}
)
}
private var rateBinding: Binding<Float> {
Binding(
get: { Float(storedRate) },
set: { newValue in
storedRate = Double(newValue)
speech.rate = newValue
}
)
}
// MARK: - Word tap definition
private func handleTap(word: String, paragraph: String) {
let cleaned = cleanWord(word)
if cleaned.isEmpty { return }
// If reading aloud, pause immediately. Remember so we can resume when
// the user dismisses the definition sheet.
if speech.isReading, !speech.isPaused {
speech.pause()
wasReadingBeforeTap = true
}
// Fall-through chain, best source first. Whichever resource answers,
// the popup names it so a curated glossary hit reads differently from
// a best-effort on-device LLM guess.
if let cached = lookupCache[cleaned] {
selectedWord = cached
return
}
if let gloss = glossary[cleaned] {
let annotation = WordAnnotation(
word: cleaned,
baseForm: gloss.baseForm,
english: gloss.english,
partOfSpeech: gloss.partOfSpeech,
source: "Book glossary"
)
lookupCache[cleaned] = annotation
selectedWord = annotation
return
}
if let entry = dictionary.lookup(cleaned) {
let annotation = WordAnnotation(
word: cleaned,
baseForm: entry.baseForm,
english: entry.english,
partOfSpeech: entry.partOfSpeech,
source: "Dictionary"
)
lookupCache[cleaned] = annotation
selectedWord = annotation
return
}
selectedWord = WordAnnotation(word: cleaned, baseForm: cleaned, english: "Looking up...", partOfSpeech: "")
Task {
do {
var annotation = try await WordLookup.lookup(word: cleaned, inContext: paragraph)
annotation.source = "AI guess"
lookupCache[cleaned] = annotation
selectedWord = annotation
} catch {
selectedWord = WordAnnotation(word: cleaned, baseForm: cleaned, english: "Lookup unavailable", partOfSpeech: "")
}
}
}
private func handleSheetDismiss() {
guard wasReadingBeforeTap else { return }
wasReadingBeforeTap = false
speech.resume()
}
private func cleanWord(_ word: String) -> String {
word.lowercased()
.trimmingCharacters(in: .punctuationCharacters)
.trimmingCharacters(in: .whitespaces)
}
}
// MARK: - Tappable paragraph
private struct TappableParagraph: View {
let text: String
let highlightedWordIndex: Int?
let onTap: (String) -> Void
var body: some View {
let words = text.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
FlowLayout(spacing: 0) {
ForEach(Array(words.enumerated()), id: \.offset) { idx, word in
WordButton(word: word, isHighlighted: idx == highlightedWordIndex, onTap: onTap)
}
}
.accessibilityElement(children: .combine)
}
}
private struct WordButton: View {
let word: String
let isHighlighted: Bool
let onTap: (String) -> Void
var body: some View {
Button {
onTap(word)
} label: {
Text(word + " ")
.font(.body)
.foregroundStyle(.primary)
.padding(.horizontal, isHighlighted ? 2 : 0)
.padding(.vertical, 1)
.background(
isHighlighted
? Color.yellow.opacity(0.35)
: Color.clear,
in: RoundedRectangle(cornerRadius: 4)
)
.animation(.easeInOut(duration: 0.15), value: isHighlighted)
}
.buttonStyle(.plain)
}
}
// MARK: - Flow layout
private struct FlowLayout: Layout {
var spacing: CGFloat = 0
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = computeRows(proposal: proposal, subviews: subviews)
var height: CGFloat = 0
for row in rows {
height += row.map { $0.height }.max() ?? 0
}
height += CGFloat(max(0, rows.count - 1)) * spacing
return CGSize(width: proposal.width ?? 0, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let rows = computeRows(proposal: proposal, subviews: subviews)
var y = bounds.minY
var subviewIndex = 0
for row in rows {
var x = bounds.minX
let rowHeight = row.map { $0.height }.max() ?? 0
for size in row {
subviews[subviewIndex].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width
subviewIndex += 1
}
y += rowHeight + spacing
}
}
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
let maxWidth = proposal.width ?? .infinity
var rows: [[CGSize]] = [[]]
var currentWidth: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if currentWidth + size.width > maxWidth && !rows[rows.count - 1].isEmpty {
rows.append([])
currentWidth = 0
}
rows[rows.count - 1].append(size)
currentWidth += size.width
}
return rows
}
}
// MARK: - Word detail sheet
private struct WordDetailSheet: View {
let word: WordAnnotation
var body: some View {
VStack(spacing: 16) {
HStack {
Text(word.word)
.font(.title2.bold())
Spacer()
if !word.partOfSpeech.isEmpty {
Text(word.partOfSpeech)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
}
Divider()
if word.english == "Looking up..." {
HStack(spacing: 8) {
ProgressView()
Text("Looking up word...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
} else {
VStack(alignment: .leading, spacing: 8) {
if !word.baseForm.isEmpty && word.baseForm != word.word {
HStack {
Text("Base form:")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(word.baseForm)
.font(.subheadline.weight(.semibold))
.italic()
}
}
if !word.english.isEmpty {
HStack {
Text("English:")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(word.english)
.font(.subheadline.weight(.semibold))
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
Spacer()
if !word.source.isEmpty {
Text(sourceLabel)
.font(.caption2)
.foregroundStyle(.tertiary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding()
}
private var sourceLabel: String {
word.source == "AI guess"
? "AI guess · on-device estimate, may be approximate"
: "Source: \(word.source)"
}
}
// MARK: - On-demand word lookup (matches StoryReaderView's WordLookup)
@MainActor
private enum WordLookup {
@Generable
struct WordInfo {
@Guide(description: "The dictionary base form (infinitive for verbs, singular for nouns)")
var baseForm: String
@Guide(description: "English translation")
var english: String
@Guide(description: "Part of speech: verb, noun, adjective, adverb, preposition, conjunction, article, pronoun, or other")
var partOfSpeech: String
}
static func lookup(word: String, inContext sentence: String) async throws -> WordAnnotation {
let session = LanguageModelSession(instructions: """
You are a Spanish dictionary. Given a word and the sentence it appears in, \
provide its base form, English translation, and part of speech.
""")
let response = try await session.respond(
to: "Word: \"\(word)\" in sentence: \"\(sentence)\"",
generating: WordInfo.self
)
let info = response.content
return WordAnnotation(
word: word,
baseForm: info.baseForm,
english: info.english,
partOfSpeech: info.partOfSpeech
)
}
}
@@ -0,0 +1,118 @@
import SwiftUI
import AVFoundation
import UIKit
/// Voice + speed picker shown from the book reader's toolbar. Lists Spanish
/// voices currently installed on the device grouped by quality, and offers a
/// shortcut to the iOS Settings app where the user can download premium voices
/// (no public deep-link to the Accessibility section exists, so we open the
/// app's own Settings page with a hint).
struct BookVoicePickerSheet: View {
@Binding var voiceIdentifier: String?
@Binding var rate: Float
@Environment(\.dismiss) private var dismiss
private struct VoiceGroup: Identifiable {
let id: String
let title: String
let voices: [AVSpeechSynthesisVoice]
}
private var groups: [VoiceGroup] {
let all = AVSpeechSynthesisVoice.speechVoices()
.filter { $0.language.hasPrefix("es") }
let buckets: [(String, AVSpeechSynthesisVoiceQuality)] = [
("Premium", .premium),
("Enhanced", .enhanced),
("Default", .default),
]
return buckets.compactMap { (title, quality) in
let voices = all
.filter { $0.quality == quality }
.sorted { lhs, rhs in
if lhs.language != rhs.language { return lhs.language < rhs.language }
return lhs.name < rhs.name
}
return voices.isEmpty ? nil : VoiceGroup(id: title, title: title, voices: voices)
}
}
var body: some View {
NavigationStack {
Form {
Section("Speed") {
Picker("Speed", selection: $rate) {
Text("Slow").tag(Float(0.40))
Text("Normal").tag(Float(0.50))
Text("Fast").tag(Float(0.55))
}
.pickerStyle(.segmented)
}
if groups.isEmpty {
Section {
ContentUnavailableView(
"No Spanish voices",
systemImage: "person.wave.2",
description: Text("Install a Spanish voice in Settings → Accessibility → Spoken Content → Voices.")
)
}
} else {
ForEach(groups) { group in
Section(group.title) {
ForEach(group.voices, id: \.identifier) { voice in
voiceRow(voice)
}
}
}
}
Section {
Button {
openSettings()
} label: {
Label("Download more voices…", systemImage: "arrow.down.circle")
}
} footer: {
Text("Opens Settings. Navigate to Accessibility → Spoken Content → Voices → Spanish to install premium or enhanced voices.")
}
}
.navigationTitle("Read aloud")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
}
private func voiceRow(_ voice: AVSpeechSynthesisVoice) -> some View {
Button {
voiceIdentifier = voice.identifier
} label: {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(voice.name)
.font(.body)
.foregroundStyle(.primary)
Text(voice.language)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if voice.identifier == voiceIdentifier {
Image(systemName: "checkmark")
.foregroundStyle(.indigo)
}
}
}
.tint(.primary)
}
private func openSettings() {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
}
@@ -1,16 +1,27 @@
import SwiftUI import SwiftUI
import SharedModels import SharedModels
import SwiftData import SwiftData
import FoundationModels
@Generable
private struct ChatWordInfo {
@Guide(description: "Dictionary base form") var baseForm: String
@Guide(description: "English translation") var english: String
@Guide(description: "Part of speech") var partOfSpeech: String
}
struct ChatView: View { struct ChatView: View {
let conversation: Conversation let conversation: Conversation
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(DictionaryService.self) private var dictionary
@State private var service = ConversationService() @State private var service = ConversationService()
@State private var messages: [ChatMessage] = [] @State private var messages: [ChatMessage] = []
@State private var inputText = "" @State private var inputText = ""
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var hasStarted = false @State private var hasStarted = false
@State private var selectedWord: WordAnnotation?
@State private var lookupCache: [String: WordAnnotation] = [:]
private var cloudContext: ModelContext { cloudModelContextProvider() } private var cloudContext: ModelContext { cloudModelContextProvider() }
@@ -21,8 +32,10 @@ struct ChatView: View {
ScrollView { ScrollView {
LazyVStack(spacing: 12) { LazyVStack(spacing: 12) {
ForEach(messages) { message in ForEach(messages) { message in
ChatBubble(message: message) ChatBubble(message: message, dictionary: dictionary, lookupCache: $lookupCache) { word in
.id(message.id) selectedWord = word
}
.id(message.id)
} }
if service.isResponding { if service.isResponding {
@@ -68,6 +81,10 @@ struct ChatView: View {
} }
.navigationTitle(conversation.scenario) .navigationTitle(conversation.scenario)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.sheet(item: $selectedWord) { word in
ChatWordDetailSheet(word: word)
.presentationDetents([.height(200)])
}
.alert("Error", isPresented: .init( .alert("Error", isPresented: .init(
get: { errorMessage != nil }, get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } } set: { if !$0 { errorMessage = nil } }
@@ -102,6 +119,7 @@ struct ChatView: View {
messages = conversation.decodedMessages messages = conversation.decodedMessages
inputText = "" inputText = ""
try? cloudContext.save() try? cloudContext.save()
ReviewStore.recordActivity(context: cloudContext)
Task { Task {
do { do {
@@ -121,6 +139,9 @@ struct ChatView: View {
private struct ChatBubble: View { private struct ChatBubble: View {
let message: ChatMessage let message: ChatMessage
let dictionary: DictionaryService
@Binding var lookupCache: [String: WordAnnotation]
let onWordTap: (WordAnnotation) -> Void
private var isUser: Bool { message.role == "user" } private var isUser: Bool { message.role == "user" }
@@ -129,11 +150,15 @@ private struct ChatBubble: View {
if isUser { Spacer(minLength: 60) } if isUser { Spacer(minLength: 60) }
VStack(alignment: isUser ? .trailing : .leading, spacing: 4) { VStack(alignment: isUser ? .trailing : .leading, spacing: 4) {
Text(message.content) if isUser {
.font(.body) Text(message.content)
.padding(.horizontal, 14) .font(.body)
.padding(.vertical, 10) .padding(.horizontal, 14)
.background(isUser ? AnyShapeStyle(.green.opacity(0.2)) : AnyShapeStyle(.fill.quaternary), in: RoundedRectangle(cornerRadius: 16)) .padding(.vertical, 10)
.background(.green.opacity(0.2), in: RoundedRectangle(cornerRadius: 16))
} else {
tappableBubble
}
if let correction = message.correction, !correction.isEmpty { if let correction = message.correction, !correction.isEmpty {
Text(correction) Text(correction)
@@ -147,4 +172,179 @@ private struct ChatBubble: View {
} }
.padding(.horizontal) .padding(.horizontal)
} }
private var tappableBubble: some View {
let words = message.content.components(separatedBy: " ")
return ChatFlowLayout(spacing: 0) {
ForEach(Array(words.enumerated()), id: \.offset) { _, word in
ChatWordButton(word: word, dictionary: dictionary, cache: lookupCache) { annotation in
if annotation.english.isEmpty {
lookupWordAsync(annotation.word)
} else {
onWordTap(annotation)
}
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 16))
}
private func lookupWordAsync(_ word: String) {
// Try dictionary first
if let entry = dictionary.lookup(word) {
let annotation = WordAnnotation(word: word, baseForm: entry.baseForm, english: entry.english, partOfSpeech: entry.partOfSpeech)
lookupCache[word] = annotation
onWordTap(annotation)
return
}
// Show loading then AI lookup
onWordTap(WordAnnotation(word: word, baseForm: word, english: "Looking up...", partOfSpeech: ""))
Task {
do {
let session = LanguageModelSession(instructions: "You are a Spanish dictionary. Provide base form, English translation, and part of speech.")
let response = try await session.respond(to: "Word: \"\(word)\"", generating: ChatWordInfo.self)
let info = response.content
let annotation = WordAnnotation(word: word, baseForm: info.baseForm, english: info.english, partOfSpeech: info.partOfSpeech)
lookupCache[word] = annotation
onWordTap(annotation)
} catch {
onWordTap(WordAnnotation(word: word, baseForm: word, english: "Lookup unavailable", partOfSpeech: ""))
}
}
}
}
// MARK: - Chat Word Button
private struct ChatWordButton: View {
let word: String
let dictionary: DictionaryService
let cache: [String: WordAnnotation]
let onTap: (WordAnnotation) -> Void
private var cleaned: String {
word.lowercased()
.trimmingCharacters(in: .punctuationCharacters)
.trimmingCharacters(in: .whitespaces)
}
private var annotation: WordAnnotation? {
if let cached = cache[cleaned] { return cached }
if let entry = dictionary.lookup(cleaned) {
return WordAnnotation(word: cleaned, baseForm: entry.baseForm, english: entry.english, partOfSpeech: entry.partOfSpeech)
}
return nil
}
var body: some View {
Button {
onTap(annotation ?? WordAnnotation(word: cleaned, baseForm: cleaned, english: "", partOfSpeech: ""))
} label: {
Text(word + " ")
.font(.body)
.foregroundStyle(.primary)
.underline(annotation != nil, color: .teal.opacity(0.3))
}
.buttonStyle(.plain)
}
}
// MARK: - Word Detail Sheet
private struct ChatWordDetailSheet: View {
let word: WordAnnotation
var body: some View {
VStack(spacing: 16) {
HStack {
Text(word.word)
.font(.title2.bold())
Spacer()
if !word.partOfSpeech.isEmpty {
Text(word.partOfSpeech)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
}
Divider()
if word.english == "Looking up..." {
HStack(spacing: 8) {
ProgressView()
Text("Looking up word...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
} else {
VStack(alignment: .leading, spacing: 8) {
if !word.baseForm.isEmpty && word.baseForm != word.word {
HStack {
Text("Base form:")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(word.baseForm)
.font(.subheadline.weight(.semibold))
.italic()
}
}
if !word.english.isEmpty {
HStack {
Text("English:")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(word.english)
.font(.subheadline.weight(.semibold))
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
Spacer()
}
.padding()
}
}
// MARK: - Chat Flow Layout
private struct ChatFlowLayout: Layout {
var spacing: CGFloat = 0
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = computeRows(proposal: proposal, subviews: subviews)
var height: CGFloat = 0
for row in rows { height += row.map { $0.height }.max() ?? 0 }
height += CGFloat(max(0, rows.count - 1)) * spacing
return CGSize(width: proposal.width ?? 0, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let rows = computeRows(proposal: proposal, subviews: subviews)
var y = bounds.minY; var idx = 0
for row in rows {
var x = bounds.minX; let rh = row.map { $0.height }.max() ?? 0
for size in row {
subviews[idx].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width; idx += 1
}
y += rh + spacing
}
}
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
let mw = proposal.width ?? .infinity; var rows: [[CGSize]] = [[]]; var cw: CGFloat = 0
for sv in subviews {
let s = sv.sizeThatFits(.unspecified)
if cw + s.width > mw && !rows[rows.count - 1].isEmpty { rows.append([]); cw = 0 }
rows[rows.count - 1].append(s); cw += s.width
}
return rows
}
} }
@@ -98,6 +98,7 @@ struct ClozeView: View {
currentIndex += 1 currentIndex += 1
selectedOption = nil selectedOption = nil
} else { } else {
ReviewStore.recordActivity(context: cloudContext)
withAnimation { isFinished = true } withAnimation { isFinished = true }
} }
} label: { } label: {
@@ -20,6 +20,7 @@ struct FullTableView: View {
@State private var useHandwriting = false @State private var useHandwriting = false
@State private var sessionCount = 0 @State private var sessionCount = 0
@State private var sessionCorrect = 0 @State private var sessionCorrect = 0
@State private var noEligibleVerbs = false
// Handwriting state per field // Handwriting state per field
@State private var drawings: [PKDrawing] = Array(repeating: PKDrawing(), count: 6) @State private var drawings: [PKDrawing] = Array(repeating: PKDrawing(), count: 6)
@@ -53,35 +54,39 @@ struct FullTableView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 32) { if noEligibleVerbs {
// Header emptyPoolError
if let verb = currentVerb, let tense = currentTense { } else {
headerSection(verb: verb, tense: tense) VStack(spacing: 32) {
} // Header
if let verb = currentVerb, let tense = currentTense {
// Input mode toggle headerSection(verb: verb, tense: tense)
HStack { }
Picker("Input", selection: $useHandwriting) {
Label("Keyboard", systemImage: "keyboard").tag(false) // Input mode toggle
Label("Pencil", systemImage: "pencil.and.outline").tag(true) HStack {
Picker("Input", selection: $useHandwriting) {
Label("Keyboard", systemImage: "keyboard").tag(false)
Label("Pencil", systemImage: "pencil.and.outline").tag(true)
}
.pickerStyle(.segmented)
}
.padding(.horizontal)
// Input fields
inputSection
// Check / Next button
actionButton
// Score
if sessionCount > 0 {
scoreSection
} }
.pickerStyle(.segmented)
}
.padding(.horizontal)
// Input fields
inputSection
// Check / Next button
actionButton
// Score
if sessionCount > 0 {
scoreSection
} }
.padding()
.adaptiveContainer()
} }
.padding()
.adaptiveContainer()
} }
.navigationTitle("Full Table") .navigationTitle("Full Table")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -91,6 +96,22 @@ struct FullTableView: View {
} }
} }
// MARK: - Empty pool error
private var emptyPoolError: some View {
VStack(spacing: 16) {
ContentUnavailableView(
"No regular verbs available",
systemImage: "exclamationmark.triangle",
description: Text(
"None of the selected tenses have any fully-regular verbs in the current settings. Enable more tenses, or turn off the Reflexive-only toggle in Settings."
)
)
}
.padding()
.adaptiveContainer()
}
// MARK: - Header // MARK: - Header
private func headerSection(verb: Verb, tense: TenseInfo) -> some View { private func headerSection(verb: Verb, tense: TenseInfo) -> some View {
@@ -243,15 +264,27 @@ struct FullTableView: View {
results = Array(repeating: nil, count: 6) results = Array(repeating: nil, count: 6)
correctForms = [] correctForms = []
drawings = Array(repeating: PKDrawing(), count: 6) drawings = Array(repeating: PKDrawing(), count: 6)
let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext) let service = PracticeSessionService(
guard let prompt = service.randomFullTablePrompt() else { localContext: modelContext,
cloudContext: cloudModelContext,
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
)
guard let prompt = service.randomFullTablePrompt(
previousTenseId: currentTense?.id,
previousEnding: currentVerb?.ending
) else {
// Genuinely no eligible (verb, tense) combo. Surface a clear error
// instead of a blank screen the previous behaviour silently
// rendered an empty header and inputs.
currentVerb = nil currentVerb = nil
currentTense = nil currentTense = nil
userAnswers = Array(repeating: "", count: 6) userAnswers = Array(repeating: "", count: 6)
focusedField = nil focusedField = nil
noEligibleVerbs = true
return return
} }
noEligibleVerbs = false
currentVerb = prompt.verb currentVerb = prompt.verb
currentTense = prompt.tenseInfo currentTense = prompt.tenseInfo
correctForms = prompt.forms correctForms = prompt.forms
@@ -312,7 +345,11 @@ struct FullTableView: View {
if allCorrect { sessionCorrect += 1 } if allCorrect { sessionCorrect += 1 }
if let verb = currentVerb, let tense = currentTense { if let verb = currentVerb, let tense = currentTense {
let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext) let service = PracticeSessionService(
localContext: modelContext,
cloudContext: cloudModelContext,
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
)
let reviewResults = Dictionary(uniqueKeysWithValues: personsToShow.map { ($0.index, results[$0.index] == true) }) let reviewResults = Dictionary(uniqueKeysWithValues: personsToShow.map { ($0.index, results[$0.index] == true) })
_ = service.recordFullTableReview(verbId: verb.id, tenseId: tense.id, results: reviewResults) _ = service.recordFullTableReview(verbId: verb.id, tenseId: tense.id, results: reviewResults)
} }
@@ -4,6 +4,8 @@ import SwiftData
struct ListeningView: View { struct ListeningView: View {
@Environment(\.modelContext) private var localContext @Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var pronunciation = PronunciationService() @State private var pronunciation = PronunciationService()
@State private var speechService = SpeechService() @State private var speechService = SpeechService()
@@ -122,6 +124,7 @@ struct ListeningView: View {
Button { Button {
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput) let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput)
if result.score >= 0.7 { correctCount += 1 } if result.score >= 0.7 { correctCount += 1 }
ReviewStore.recordActivity(context: cloudModelContext)
withAnimation { isRevealed = true } withAnimation { isRevealed = true }
} label: { } label: {
Text("Check") Text("Check")
@@ -164,6 +167,7 @@ struct ListeningView: View {
score = result.score score = result.score
wordMatches = result.matches wordMatches = result.matches
if result.score >= 0.7 { correctCount += 1 } if result.score >= 0.7 { correctCount += 1 }
ReviewStore.recordActivity(context: cloudModelContext)
withAnimation { isRevealed = true } withAnimation { isRevealed = true }
} else { } else {
pronunciation.startRecording() pronunciation.startRecording()
@@ -1,9 +1,16 @@
import SwiftUI import SwiftUI
import SwiftData
import SharedModels import SharedModels
struct LyricsReaderView: View { struct LyricsReaderView: View {
let song: SavedSong let song: SavedSong
@Environment(DictionaryService.self) private var dictionary
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var selectedWord: LyricsWordLookup?
@State private var lookupCache: [String: LyricsWordLookup] = [:]
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 20) { VStack(spacing: 20) {
@@ -15,6 +22,10 @@ struct LyricsReaderView: View {
} }
.navigationTitle(song.title) .navigationTitle(song.title)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.sheet(item: $selectedWord) { word in
LyricsWordDetailSheet(word: word)
.presentationDetents([.height(260)])
}
} }
// MARK: - Header // MARK: - Header
@@ -56,15 +67,6 @@ struct LyricsReaderView: View {
let spanishLines = song.lyricsES.components(separatedBy: "\n") let spanishLines = song.lyricsES.components(separatedBy: "\n")
let englishLines = song.lyricsEN.components(separatedBy: "\n") let englishLines = song.lyricsEN.components(separatedBy: "\n")
let lineCount = max(spanishLines.count, englishLines.count) let lineCount = max(spanishLines.count, englishLines.count)
let _ = {
print("[LyricsReader] ES lines: \(spanishLines.count), EN lines: \(englishLines.count), rendering: \(lineCount)")
for i in 0..<min(15, lineCount) {
let es = i < spanishLines.count ? spanishLines[i] : "(none)"
let en = i < englishLines.count ? englishLines[i] : "(none)"
print(" [\(i)] ES: \(es.isEmpty ? "(blank)" : es)")
print(" EN: \(en.isEmpty ? "(blank)" : en)")
}
}()
return VStack(alignment: .leading, spacing: 0) { return VStack(alignment: .leading, spacing: 0) {
ForEach(0..<lineCount, id: \.self) { index in ForEach(0..<lineCount, id: \.self) { index in
@@ -78,8 +80,7 @@ struct LyricsReaderView: View {
} else { } else {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
if !es.isEmpty { if !es.isEmpty {
Text(es) spanishLine(es)
.font(.body.weight(.medium))
} }
if !en.isEmpty { if !en.isEmpty {
Text(en) Text(en)
@@ -94,4 +95,184 @@ struct LyricsReaderView: View {
.padding() .padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12)) .background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
} }
private func spanishLine(_ line: String) -> some View {
let tokens = line.components(separatedBy: " ")
return LyricsFlowLayout(spacing: 0) {
ForEach(Array(tokens.enumerated()), id: \.offset) { _, token in
LyricsWordView(token: token, lookup: makeLookup(for: token)) { word in
ReviewStore.recordActivity(context: cloudModelContext)
selectedWord = word
}
}
}
}
// MARK: - Lookup
private func makeLookup(for rawToken: String) -> LyricsWordLookup? {
let cleaned = rawToken.lowercased()
.trimmingCharacters(in: .punctuationCharacters)
.trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return nil }
if let cached = lookupCache[cleaned] { return cached }
guard let entry = dictionary.lookup(cleaned) else { return nil }
let displayWord = rawToken
.trimmingCharacters(in: .punctuationCharacters)
.trimmingCharacters(in: .whitespaces)
let tenseDisplay = entry.tenseId.flatMap { TenseInfo.find($0)?.english }
let lookup = LyricsWordLookup(
word: displayWord.isEmpty ? entry.word : displayWord,
baseForm: entry.baseForm,
english: entry.english,
partOfSpeech: entry.partOfSpeech,
tenseDisplay: tenseDisplay,
person: entry.person
)
lookupCache[cleaned] = lookup
return lookup
}
}
// MARK: - Word Lookup Model
private struct LyricsWordLookup: Identifiable, Hashable {
let word: String
let baseForm: String
let english: String
let partOfSpeech: String
let tenseDisplay: String?
let person: String?
var id: String { word }
}
// MARK: - Word View
private struct LyricsWordView: View {
let token: String
let lookup: LyricsWordLookup?
let onLookup: (LyricsWordLookup) -> Void
var body: some View {
Text(token + " ")
.font(.body.weight(.medium))
.foregroundStyle(.primary)
.underline(lookup != nil, color: .teal.opacity(0.35))
.contentShape(Rectangle())
.onLongPressGesture(minimumDuration: 0.35) {
if let lookup {
onLookup(lookup)
}
}
}
}
// MARK: - Detail Sheet
private struct LyricsWordDetailSheet: View {
let word: LyricsWordLookup
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text(word.word)
.font(.title2.bold())
Spacer()
if !word.partOfSpeech.isEmpty {
Text(word.partOfSpeech)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
}
Divider()
VStack(alignment: .leading, spacing: 10) {
if !word.baseForm.isEmpty && word.baseForm.lowercased() != word.word.lowercased() {
detailRow(label: "Base form", value: word.baseForm, italic: true)
}
if !word.english.isEmpty {
detailRow(label: "English", value: word.english)
}
if let tenseDisplay = word.tenseDisplay {
let personSuffix = (word.person?.isEmpty == false) ? " · \(word.person!)" : ""
detailRow(label: "Tense", value: tenseDisplay + personSuffix)
}
}
Spacer(minLength: 0)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private func detailRow(label: String, value: String, italic: Bool = false) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("\(label):")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(width: 86, alignment: .leading)
Text(value)
.font(.subheadline.weight(.semibold))
.italic(italic)
Spacer(minLength: 0)
}
}
}
// MARK: - Flow Layout
private struct LyricsFlowLayout: Layout {
var spacing: CGFloat = 0
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = computeRows(proposal: proposal, subviews: subviews)
var height: CGFloat = 0
for row in rows { height += row.map { $0.height }.max() ?? 0 }
height += CGFloat(max(0, rows.count - 1)) * spacing
return CGSize(width: proposal.width ?? 0, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let rows = computeRows(proposal: proposal, subviews: subviews)
var y = bounds.minY
var idx = 0
for row in rows {
var x = bounds.minX
let rh = row.map { $0.height }.max() ?? 0
for size in row {
subviews[idx].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width
idx += 1
}
y += rh + spacing
}
}
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
let mw = proposal.width ?? .infinity
var rows: [[CGSize]] = [[]]
var cw: CGFloat = 0
for sv in subviews {
let s = sv.sizeThatFits(.unspecified)
if cw + s.width > mw && !rows[rows.count - 1].isEmpty {
rows.append([])
cw = 0
}
rows[rows.count - 1].append(s)
cw += s.width
}
return rows
}
} }
+213 -160
View File
@@ -21,6 +21,19 @@ struct PracticeView: View {
practiceHomeView practiceHomeView
} }
} }
// Book navigation is value-based and declared once here, at the
// stack root. Eager `NavigationLink { destination }` forms inside
// the List/LazyVStack of the book screens caused an infinite
// render loop; value-based links build destinations lazily.
.navigationDestination(for: BooksRoute.self) { _ in
BookLibraryView()
}
.navigationDestination(for: Book.self) { book in
BookChapterListView(book: book)
}
.navigationDestination(for: BookChapter.self) { chapter in
BookReaderView(chapter: chapter)
}
.navigationTitle("Practice") .navigationTitle("Practice")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadProgress) .onAppear(perform: loadProgress)
@@ -74,12 +87,10 @@ struct PracticeView: View {
.padding(.top, 8) .padding(.top, 8)
} }
// Mode selection // === Section: Conjugation ===
VStack(spacing: 12) { sectionHeader("Conjugation")
Text("Choose a Mode")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
VStack(spacing: 12) {
ForEach(PracticeMode.allCases) { mode in ForEach(PracticeMode.allCases) { mode in
ModeButton(mode: mode) { ModeButton(mode: mode) {
viewModel.practiceMode = mode viewModel.practiceMode = mode
@@ -98,6 +109,15 @@ struct PracticeView: View {
} }
.padding(.horizontal) .padding(.horizontal)
conjugationFocusButtons
// === Section: Vocabulary ===
sectionHeader("Vocabulary")
vocabSection
// === Section: Reading ===
sectionHeader("Reading")
// Lyrics // Lyrics
NavigationLink { NavigationLink {
LyricsLibraryView() LyricsLibraryView()
@@ -253,166 +273,33 @@ struct PracticeView: View {
.glassEffect(in: RoundedRectangle(cornerRadius: 14)) .glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal) .padding(.horizontal)
// Quick Actions // Books
VStack(spacing: 12) { NavigationLink(value: BooksRoute.library) {
Text("Quick Actions") HStack(spacing: 14) {
.font(.headline) Image(systemName: "books.vertical.fill")
.frame(maxWidth: .infinity, alignment: .leading) .font(.title3)
.frame(width: 36)
.foregroundStyle(.indigo)
// Vocab review VStack(alignment: .leading, spacing: 2) {
NavigationLink { Text("Books")
VocabReviewView() .font(.subheadline.weight(.semibold))
} label: { Text("Read full-length books with tap-to-define")
HStack(spacing: 14) {
Image(systemName: "rectangle.stack.fill")
.font(.title3)
.foregroundStyle(.teal)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Vocab Review")
.font(.subheadline.weight(.semibold))
Text("Review due vocabulary cards")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
let dueCount = VocabReviewView.dueCount(context: cloudModelContext)
if dueCount > 0 {
Text("\(dueCount)")
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.teal, in: Capsule())
}
Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundStyle(.tertiary) .foregroundStyle(.secondary)
} }
.padding(.horizontal, 16)
.padding(.vertical, 12) Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
} }
.tint(.primary) .padding(.horizontal, 16)
.glassEffect(in: RoundedRectangle(cornerRadius: 14)) .padding(.vertical, 14)
// Common tenses focus
Button {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .commonTenses
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
withAnimation { isPracticing = true }
} label: {
HStack(spacing: 14) {
Image(systemName: "star.fill")
.font(.title3)
.foregroundStyle(.orange)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Common Tenses")
.font(.subheadline.weight(.semibold))
Text("Practice the 6 most essential tenses")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Weak verbs focus
Button {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .weakVerbs
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
withAnimation { isPracticing = true }
} label: {
HStack(spacing: 14) {
Image(systemName: "exclamationmark.triangle")
.font(.title3)
.foregroundStyle(.red)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Weak Verbs")
.font(.subheadline.weight(.semibold))
Text("Focus on verbs you struggle with")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Irregularity drills
Menu {
Button("Spelling Changes (c→qu, z→c, ...)") {
startIrregularityDrill(.spelling)
}
Button("Stem Changes (o→ue, e→ie, ...)") {
startIrregularityDrill(.stemChange)
}
Button("Unique Irregulars (ser, ir, ...)") {
startIrregularityDrill(.uniqueIrregular)
}
} label: {
HStack(spacing: 14) {
Image(systemName: "wand.and.stars")
.font(.title3)
.foregroundStyle(.purple)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Irregularity Drills")
.font(.subheadline.weight(.semibold))
Text("Practice by irregularity type")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
} }
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal) .padding(.horizontal)
// Session stats summary // Session stats summary
@@ -442,6 +329,172 @@ struct PracticeView: View {
} }
} }
// MARK: - Section header
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
}
// MARK: - Conjugation focus buttons (Common Tenses / Weak Verbs / Irregularity)
private var conjugationFocusButtons: some View {
VStack(spacing: 12) {
// Common Tenses
Button {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .commonTenses
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext)
withAnimation { isPracticing = true }
} label: {
practiceRowLabel(icon: "star.fill", color: .orange,
title: "Common Tenses",
subtitle: "Practice the 6 most essential tenses")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Weak Verbs
Button {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .weakVerbs
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext)
withAnimation { isPracticing = true }
} label: {
practiceRowLabel(icon: "exclamationmark.triangle", color: .red,
title: "Weak Verbs",
subtitle: "Focus on verbs you struggle with")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Irregularity drills
Menu {
Button("Spelling Changes (c→qu, z→c, ...)") { startIrregularityDrill(.spelling) }
Button("Stem Changes (o→ue, e→ie, ...)") { startIrregularityDrill(.stemChange) }
Button("Unique Irregulars (ser, ir, ...)") { startIrregularityDrill(.uniqueIrregular) }
} label: {
practiceRowLabel(icon: "wand.and.stars", color: .purple,
title: "Irregularity Drills",
subtitle: "Practice by irregularity type")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal)
}
// MARK: - Vocabulary section
private var vocabSection: some View {
VStack(spacing: 12) {
// Vocab Flashcards (verb pool, filtered by Settings levels)
NavigationLink {
VocabFlashcardPracticeView()
} label: {
practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .purple,
title: "Vocab Flashcards",
subtitle: "Verb meaning → infinitive recall")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Vocab Multiple Choice (same verb pool)
NavigationLink {
VocabMultipleChoicePracticeView()
} label: {
practiceRowLabel(icon: "checklist", color: .purple,
title: "Vocab Multiple Choice",
subtitle: "Pick the Spanish infinitive from 4 options")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Review Learned consolidation cram over already-studied verbs
NavigationLink {
VocabFlashcardPracticeView(kind: .reviewLearned)
} label: {
practiceRowLabel(icon: "clock.arrow.circlepath", color: .purple,
title: "Review Learned",
subtitle: "Re-review verbs you've studied — schedule unchanged")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Existing: Vocab Review (due cards)
NavigationLink {
VocabReviewView()
} label: {
HStack(spacing: 14) {
Image(systemName: "rectangle.stack.fill")
.font(.title3)
.foregroundStyle(.teal)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Vocab Review")
.font(.subheadline.weight(.semibold))
Text("Review due vocabulary cards")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
let dueCount = VocabReviewView.dueCount(context: cloudModelContext)
if dueCount > 0 {
Text("\(dueCount)")
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.teal, in: Capsule())
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal)
}
private func practiceRowLabel(icon: String, color: Color, title: String, subtitle: String) -> some View {
HStack(spacing: 14) {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(color)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
// MARK: - Practice Session View // MARK: - Practice Session View
@ViewBuilder @ViewBuilder
@@ -4,6 +4,8 @@ import SwiftData
struct SentenceBuilderView: View { struct SentenceBuilderView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var currentCard: VocabCard? @State private var currentCard: VocabCard?
@State private var exampleIndex: Int = 0 @State private var exampleIndex: Int = 0
@@ -316,6 +318,7 @@ struct SentenceBuilderView: View {
if isCorrect { if isCorrect {
sessionCorrect += 1 sessionCorrect += 1
} }
ReviewStore.recordActivity(context: cloudModelContext)
} }
private func fetchRandomSentenceSelection() -> (card: VocabCard, exampleIndex: Int, spanish: String)? { private func fetchRandomSentenceSelection() -> (card: VocabCard, exampleIndex: Int, spanish: String)? {
@@ -1,9 +1,13 @@
import SwiftUI import SwiftUI
import SwiftData
import SharedModels import SharedModels
struct StoryQuizView: View { struct StoryQuizView: View {
let story: Story let story: Story
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var currentIndex = 0 @State private var currentIndex = 0
@State private var selectedOption: Int? @State private var selectedOption: Int?
@State private var correctCount = 0 @State private var correctCount = 0
@@ -85,6 +89,7 @@ struct StoryQuizView: View {
currentIndex += 1 currentIndex += 1
selectedOption = nil selectedOption = nil
} else { } else {
ReviewStore.recordActivity(context: cloudModelContext)
withAnimation { isFinished = true } withAnimation { isFinished = true }
} }
} label: { } label: {
@@ -0,0 +1,518 @@
import SwiftUI
import SharedModels
import SwiftData
/// English-first verb flashcards with two modes, switched from the toolbar:
///
/// - **Quiz** the SRS path. `VocabSessionQueue` learning-step queue:
/// tap to reveal, rate Again/Hard/Good/Easy, cards requeue, graduation
/// feeds the long-term `VerbReviewStore` schedule.
/// - **Learn** no-pressure browsing. Both sides shown at once (English +
/// Spanish + example), Next/Previous step through the same session pool
/// on a loop. No rating, no SRS side effects.
/// Which pool a flashcard session draws from.
enum VocabSessionKind {
/// Due-first + new verbs, capped the standard SRS session. Ratings
/// update the long-term schedule.
case standard
/// Verbs already studied at least once, most-recent first, uncapped a
/// consolidation cram. Ratings drive the in-session queue only and do NOT
/// reschedule (the long-term SM-2 due dates are left untouched).
case reviewLearned
}
struct VocabFlashcardPracticeView: View {
enum Mode: String { case quiz, learn }
var kind: VocabSessionKind = .standard
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(VerbExampleCache.self) private var exampleCache
@Environment(\.dismiss) private var dismiss
@AppStorage("vocabFlashcardMode") private var modeRaw: String = Mode.quiz.rawValue
@State private var session: VocabSessionQueue?
@State private var learnIndex: Int = 0
@State private var revealed: Bool = false
@State private var exampleByVerbId: [Int: VerbExample] = [:]
@State private var generatingVerbIds: Set<Int> = []
@State private var speech = SpeechService()
private var cloudContext: ModelContext { cloudModelContextProvider() }
/// The session's un-graduated verbs, derived live from the quiz queue so
/// Learn mode walks exactly what's left to learn it stays in sync as
/// quiz mode graduates cards rather than browsing a stale frozen pool.
private var sessionVerbs: [Verb] {
session?.queue.map(\.verb) ?? []
}
private var mode: Mode {
get { Mode(rawValue: modeRaw) ?? .quiz }
nonmutating set { modeRaw = newValue.rawValue }
}
private var currentVerb: Verb? {
switch mode {
case .quiz:
return session?.current?.verb
case .learn:
guard !sessionVerbs.isEmpty else { return nil }
return sessionVerbs[learnIndex % sessionVerbs.count]
}
}
var body: some View {
ScrollView {
VStack(spacing: 24) {
headerBar
switch mode {
case .quiz:
quizContent
case .learn:
learnContent
}
}
.padding()
.adaptiveContainer(maxWidth: 720)
}
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Vocab Flashcards")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Picker("Mode", selection: Binding(
get: { mode },
set: { mode = $0 }
)) {
Label("Quiz (SRS)", systemImage: "checklist").tag(Mode.quiz)
Label("Learn", systemImage: "book").tag(Mode.learn)
}
} label: {
Label(
mode == .learn ? "Learn" : "Quiz",
systemImage: mode == .learn ? "book" : "checklist"
)
}
}
}
.onAppear(perform: loadIfNeeded)
.onChange(of: modeRaw) { _, _ in
revealed = false
primeExampleForCurrent()
}
.animation(.smooth, value: revealed)
.animation(.smooth, value: currentVerb?.id)
}
// MARK: - Header
@ViewBuilder
private var headerBar: some View {
VStack(spacing: 6) {
switch mode {
case .quiz:
ProgressView(value: session?.progress ?? 0)
.tint(.purple)
Text(quizProgressLabel)
.font(.caption)
.foregroundStyle(.secondary)
case .learn:
if sessionVerbs.isEmpty {
Text(emptyPoolMessage)
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("\(learnIndex % sessionVerbs.count + 1) of \(sessionVerbs.count)")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
}
if kind == .reviewLearned {
Text("Practice pass — your review schedule won't change.")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
private var emptyPoolMessage: String {
switch kind {
case .standard:
return "No verbs match the levels enabled in Settings"
case .reviewLearned:
return "Nothing studied yet — finish a Vocab Flashcards session first"
}
}
private var quizProgressLabel: String {
guard let session else { return "Loading…" }
if session.isComplete { return "Done" }
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
}
// MARK: - Quiz mode
@ViewBuilder
private var quizContent: some View {
if let verb = currentVerb {
Text(verb.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if revealed {
quizRevealed(verb)
} else {
tapToReveal
}
} else {
completionView
}
}
private var tapToReveal: some View {
VStack(spacing: 8) {
Image(systemName: "hand.tap")
.font(.title)
.foregroundStyle(.secondary)
Text("Tap to reveal")
.font(.headline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.frame(minHeight: 200)
.glassEffect(in: RoundedRectangle(cornerRadius: 20))
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.smooth) { revealed = true }
}
}
private func quizRevealed(_ verb: Verb) -> some View {
VStack(spacing: 18) {
spanishRow(verb)
exampleBlock(for: verb)
ratingButtons
}
}
private var ratingButtons: some View {
VStack(spacing: 10) {
Text("How well did you know it?")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
ratingButton("Again", color: .red, rating: .again)
ratingButton("Hard", color: .orange, rating: .hard)
ratingButton("Good", color: .green, rating: .good)
ratingButton("Easy", color: .blue, rating: .easy)
}
}
}
private func ratingButton(_ label: String, color: Color, rating: VocabSessionQueue.Rating) -> some View {
Button {
answer(rating)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(color)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
private var completionView: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 56))
.foregroundStyle(.green)
Text(completionTitle)
.font(.title2.bold())
Text(completionDetail)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button {
studyAgain()
} label: {
Label(
kind == .reviewLearned ? "Study Again" : "Next Set",
systemImage: kind == .reviewLearned ? "arrow.clockwise" : "arrow.right"
)
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.purple)
Button("Done") { dismiss() }
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
.buttonStyle(.bordered)
}
.padding(.top, 12)
}
.padding(.top, 60)
.padding(.horizontal, 24)
}
private var completionTitle: String {
let learned = session?.learnedCount ?? 0
switch kind {
case .standard:
return learned > 0 ? "Session Complete" : "Nothing Due"
case .reviewLearned:
return learned > 0 ? "Review Complete" : "Nothing to Review"
}
}
private var completionDetail: String {
let learned = session?.learnedCount ?? 0
if learned > 0 {
let noun = kind == .reviewLearned ? "reviewed" : "learned"
return "\(learned) verb\(learned == 1 ? "" : "s") \(noun)"
}
switch kind {
case .standard:
return "No verbs are due right now. Study Again to review anyway."
case .reviewLearned:
return "Finish a Vocab Flashcards session first, then come back to consolidate."
}
}
// MARK: - Learn mode
@ViewBuilder
private var learnContent: some View {
if let verb = currentVerb {
Text(verb.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
spanishRow(verb)
exampleBlock(for: verb)
HStack(spacing: 12) {
Button {
learnStep(-1)
} label: {
Label("Previous", systemImage: "chevron.left")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(.secondary)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
Button {
learnStep(1)
} label: {
Label("Next", systemImage: "chevron.right")
.labelStyle(.titleAndIcon)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(.purple)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
} else {
ContentUnavailableView(
"Nothing to Learn",
systemImage: "book",
description: Text("Enable some verb levels in Settings.")
)
.padding(.top, 40)
}
}
private func learnStep(_ delta: Int) {
guard !sessionVerbs.isEmpty else { return }
let count = sessionVerbs.count
learnIndex = ((learnIndex + delta) % count + count) % count
primeExampleForCurrent()
}
// MARK: - Shared card pieces
private func spanishRow(_ verb: Verb) -> some View {
HStack(spacing: 12) {
Text(verb.infinitive)
.font(.title.weight(.semibold))
.multilineTextAlignment(.center)
Button {
speech.speak(verb.infinitive)
} label: {
Image(systemName: "speaker.wave.2.fill")
.font(.title3)
.padding(10)
}
.glassEffect(in: .circle)
.accessibilityLabel("Say it out loud")
}
}
@ViewBuilder
private func exampleBlock(for verb: Verb) -> some View {
if let example = exampleByVerbId[verb.id] {
VStack(alignment: .leading, spacing: 4) {
Text(example.spanish).font(.subheadline).italic()
Text(example.english).font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
} else if generatingVerbIds.contains(verb.id) {
HStack(spacing: 8) {
ProgressView()
Text("Generating example…")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Logic
private func loadIfNeeded() {
guard session == nil else { return }
switch kind {
case .standard:
loadStandardSession()
case .reviewLearned:
let verbs = VocabVerbPool.reviewLearnedVerbs(localContext: localContext, cloudContext: cloudContext)
session = VocabSessionQueue(verbs: verbs)
}
primeExampleForCurrent()
}
/// Resume the persisted, cross-device study group if one is active;
/// otherwise start a fresh group and persist it.
private func loadStandardSession() {
let store = VocabStudyGroupStore(context: cloudContext)
if let group = store.activeGroup() {
let stored = group.entries
if !stored.isEmpty {
let byId = verbsByID(Set(stored.map(\.verbId)))
let entries: [(verb: Verb, state: VocabSessionQueue.CardState)] = stored.compactMap { e in
guard let verb = byId[e.verbId] else { return nil }
return (verb, VocabSessionQueue.CardState(rawValue: e.state) ?? .new)
}
// Resume only if EVERY stored verb resolved. A partial resume
// would desync learnedCount from a shrunken queue and then
// persist that loss fall through to a fresh rebuild instead.
if entries.count == stored.count {
session = VocabSessionQueue(entries: entries, learnedCount: group.learnedCount)
return
}
}
}
// No active group (or it couldn't be fully resolved) start fresh and
// persist immediately so closing the app right away still resumes this set.
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
session = VocabSessionQueue(verbs: verbs)
persistGroup()
}
private func verbsByID(_ ids: Set<Int>) -> [Int: Verb] {
let all = (try? localContext.fetch(FetchDescriptor<Verb>())) ?? []
var map: [Int: Verb] = [:]
for verb in all where ids.contains(verb.id) { map[verb.id] = verb }
return map
}
/// Write the standard session's progress to the cloud-synced study group,
/// or clear the group when the set is fully learned.
private func persistGroup() {
guard kind == .standard, let session else { return }
let store = VocabStudyGroupStore(context: cloudContext)
if session.isComplete {
store.clear()
} else {
let entries = session.snapshot().map {
StoredVocabEntry(verbId: $0.verbId, state: $0.state.rawValue)
}
store.persist(entries: entries, learnedCount: session.learnedCount)
}
}
private func studyAgain() {
switch kind {
case .standard:
// Clear the finished group explicitly before building a fresh one,
// so a fresh set is never appended onto a stale group record.
VocabStudyGroupStore(context: cloudContext).clear()
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
session = VocabSessionQueue(verbs: verbs)
persistGroup()
case .reviewLearned:
session?.restart()
}
learnIndex = 0
revealed = false
primeExampleForCurrent()
}
private func answer(_ rating: VocabSessionQueue.Rating) {
guard let verbId = currentVerb?.id else { return }
let graduation = session?.answer(rating) ?? nil
// Review Learned is a cram pass graduation drives the in-session
// queue only; the long-term SM-2 schedule is left untouched.
if let graduation, kind == .standard {
VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation)
}
persistGroup()
withAnimation(.smooth) { revealed = false }
primeExampleForCurrent()
}
private func primeExampleForCurrent() {
guard let verb = currentVerb else { return }
let verbId = verb.id
if exampleByVerbId[verbId] != nil || generatingVerbIds.contains(verbId) { return }
if let cached = exampleCache.examples(for: verbId)?.first {
exampleByVerbId[verbId] = cached
return
}
guard VerbExampleGenerator.isAvailable else { return }
generatingVerbIds.insert(verbId)
let infinitive = verb.infinitive
let english = verb.english
let formsByTense = ReferenceStore(context: localContext)
.conjugatedForms(verbId: verbId, tenseIds: VocabExampleTenseIds.canonical)
Task {
do {
let examples = try await VerbExampleGenerator.generate(
verbInfinitive: infinitive,
verbEnglish: english,
tenseIds: VocabExampleTenseIds.canonical,
formsByTense: formsByTense
)
exampleCache.setExamples(examples, for: verbId)
let pick = examples.first { $0.tenseId == "ind_presente" } ?? examples.first
if let pick, currentVerb?.id == verbId {
exampleByVerbId[verbId] = pick
}
} catch {
// Silent the example block just stays hidden.
}
generatingVerbIds.remove(verbId)
}
}
}
@@ -0,0 +1,296 @@
import SwiftUI
import SharedModels
import SwiftData
/// English-first verb multiple choice, driven by `VocabSessionQueue`. 4 options
/// (1 correct + 3 random distractors from the session pool). After answering:
/// reveal correct/incorrect, the verb infinitive, an example sentence, and SRS
/// rating buttons. Again/Hard requeue; a second Good or an Easy graduates.
struct VocabMultipleChoicePracticeView: View {
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(VerbExampleCache.self) private var exampleCache
@Environment(\.dismiss) private var dismiss
@State private var session: VocabSessionQueue?
@State private var distractorPool: [Verb] = []
@State private var options: [Verb] = []
@State private var selectedOption: Verb? = nil
@State private var exampleByVerbId: [Int: VerbExample] = [:]
@State private var generatingVerbIds: Set<Int> = []
@State private var speech = SpeechService()
private var cloudContext: ModelContext { cloudModelContextProvider() }
private var currentVerb: Verb? { session?.current?.verb }
var body: some View {
ScrollView {
VStack(spacing: 22) {
progressBar
if let verb = currentVerb {
questionBody(verb)
} else {
completionView
}
}
.padding()
.adaptiveContainer(maxWidth: 720)
}
.navigationTitle("Vocab Multiple Choice")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded)
.animation(.smooth, value: selectedOption?.id)
.animation(.smooth, value: currentVerb?.id)
}
// MARK: - Progress
private var progressBar: some View {
VStack(spacing: 6) {
ProgressView(value: session?.progress ?? 0)
.tint(.purple)
Text(progressLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var progressLabel: String {
guard let session else { return "Loading…" }
if session.isComplete { return "Done" }
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
}
// MARK: - Question
@ViewBuilder
private func questionBody(_ verb: Verb) -> some View {
Text(verb.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if selectedOption == nil {
optionGrid
} else {
revealedContent(verb)
}
}
private var optionGrid: some View {
VStack(spacing: 10) {
ForEach(options, id: \.id) { option in
Button {
selectedOption = option
} label: {
Text(option.infinitive)
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.tint(.primary)
}
}
}
private func revealedContent(_ verb: Verb) -> some View {
VStack(spacing: 16) {
answerFeedback(verb)
exampleBlock(for: verb)
ratingButtons
}
}
private func answerFeedback(_ verb: Verb) -> some View {
let correct = (selectedOption?.id == verb.id)
return VStack(spacing: 6) {
Image(systemName: correct ? "checkmark.circle.fill" : "xmark.circle.fill")
.font(.system(size: 36))
.foregroundStyle(correct ? .green : .red)
Text(correct ? "Correct!" : "Not quite")
.font(.headline)
.foregroundStyle(correct ? .green : .red)
HStack(spacing: 10) {
Text(verb.infinitive)
.font(.title2.weight(.semibold))
Button {
speech.speak(verb.infinitive)
} label: {
Image(systemName: "speaker.wave.2.fill")
.font(.body)
.padding(8)
}
.glassEffect(in: .circle)
.accessibilityLabel("Say it out loud")
}
.padding(.top, 4)
}
}
@ViewBuilder
private func exampleBlock(for verb: Verb) -> some View {
if let example = exampleByVerbId[verb.id] {
VStack(alignment: .leading, spacing: 4) {
Text(example.spanish).font(.subheadline).italic()
Text(example.english).font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
} else if generatingVerbIds.contains(verb.id) {
HStack(spacing: 8) {
ProgressView()
Text("Generating example…")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
private var ratingButtons: some View {
VStack(spacing: 10) {
Text("How well did you know it?")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
ratingButton("Again", color: .red, rating: .again)
ratingButton("Hard", color: .orange, rating: .hard)
ratingButton("Good", color: .green, rating: .good)
ratingButton("Easy", color: .blue, rating: .easy)
}
}
}
private func ratingButton(_ label: String, color: Color, rating: VocabSessionQueue.Rating) -> some View {
Button {
answer(rating)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(color)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
// MARK: - Completion
private var completionView: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 56))
.foregroundStyle(.green)
Text((session?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due")
.font(.title2.bold())
Text(completionDetail)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button {
studyAgain()
} label: {
Label("Study Again", systemImage: "arrow.clockwise")
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.purple)
Button("Done") { dismiss() }
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
.buttonStyle(.bordered)
}
.padding(.top, 12)
}
.padding(.top, 60)
.padding(.horizontal, 24)
}
private var completionDetail: String {
let learned = session?.learnedCount ?? 0
if learned > 0 {
return "\(learned) verb\(learned == 1 ? "" : "s") learned"
}
return "No verbs are due right now. Study Again to review anyway."
}
// MARK: - Logic
private func loadIfNeeded() {
guard session == nil else { return }
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
distractorPool = verbs
session = VocabSessionQueue(verbs: verbs)
prepareOptions()
primeExampleForCurrent()
}
private func studyAgain() {
session?.restart()
selectedOption = nil
prepareOptions()
primeExampleForCurrent()
}
private func prepareOptions() {
guard let verb = currentVerb else { options = []; return }
let candidates = distractorPool.filter { $0.id != verb.id }
let distractors = Array(candidates.shuffled().prefix(3))
options = ([verb] + distractors).shuffled()
}
private func answer(_ rating: VocabSessionQueue.Rating) {
guard let verbId = currentVerb?.id else { return }
let graduation = session?.answer(rating) ?? nil
if let graduation {
VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation)
}
selectedOption = nil
prepareOptions()
primeExampleForCurrent()
}
private func primeExampleForCurrent() {
guard let verb = currentVerb else { return }
let verbId = verb.id
if exampleByVerbId[verbId] != nil || generatingVerbIds.contains(verbId) { return }
if let cached = exampleCache.examples(for: verbId)?.first {
exampleByVerbId[verbId] = cached
return
}
guard VerbExampleGenerator.isAvailable else { return }
generatingVerbIds.insert(verbId)
let infinitive = verb.infinitive
let english = verb.english
let formsByTense = ReferenceStore(context: localContext)
.conjugatedForms(verbId: verbId, tenseIds: VocabExampleTenseIds.canonical)
Task {
do {
let examples = try await VerbExampleGenerator.generate(
verbInfinitive: infinitive,
verbEnglish: english,
tenseIds: VocabExampleTenseIds.canonical,
formsByTense: formsByTense
)
exampleCache.setExamples(examples, for: verbId)
let pick = examples.first { $0.tenseId == "ind_presente" } ?? examples.first
if let pick, currentVerb?.id == verbId {
exampleByVerbId[verbId] = pick
}
} catch {}
generatingVerbIds.remove(verbId)
}
}
}
@@ -130,6 +130,7 @@ struct VocabReviewView: View {
private func rate(quality: ReviewQuality) { private func rate(quality: ReviewQuality) {
guard let card = dueCards[safe: currentIndex] else { return } guard let card = dueCards[safe: currentIndex] else { return }
ReviewStore.recordActivity(context: cloudContext)
let store = CourseReviewStore(context: cloudContext) let store = CourseReviewStore(context: cloudContext)
let result = SRSEngine.review( let result = SRSEngine.review(
quality: quality, quality: quality,
@@ -0,0 +1,99 @@
import SwiftUI
import SwiftData
import SharedModels
/// Lists downloaded YouTube videos with per-item deletion and total-size
/// summary (Issue #21, phase 4). Files live in the local (non-synced)
/// SwiftData container and the app's documents directory.
struct DownloadedVideosView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \DownloadedVideo.downloadedAt, order: .reverse)
private var downloads: [DownloadedVideo]
@State private var downloadService = VideoDownloadService.shared
@State private var confirmDeleteAll = false
private var totalBytes: Int {
downloads.reduce(0) { $0 + $1.byteCount }
}
var body: some View {
List {
if downloads.isEmpty {
Section {
ContentUnavailableView(
"No downloads",
systemImage: "arrow.down.to.line",
description: Text("Tap Download on any guide or grammar video to save it for offline viewing.")
)
}
} else {
Section {
LabeledContent("Total size", value: sizeString(totalBytes))
if totalBytes > 500_000_000 {
Label("Downloads exceed 500 MB", systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
}
}
Section("Videos") {
ForEach(downloads) { download in
VStack(alignment: .leading, spacing: 4) {
Text(download.title)
.font(.subheadline.weight(.medium))
.lineLimit(2)
HStack {
Text(sizeString(download.byteCount))
Text("·")
Text(download.downloadedAt.formatted(date: .abbreviated, time: .omitted))
}
.font(.caption)
.foregroundStyle(.secondary)
}
.swipeActions {
Button(role: .destructive) {
downloadService.delete(videoId: download.videoId, modelContext: modelContext)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
Section {
Button(role: .destructive) {
confirmDeleteAll = true
} label: {
Label("Delete all downloads", systemImage: "trash")
}
}
}
}
.navigationTitle("Downloaded Videos")
.navigationBarTitleDisplayMode(.inline)
.confirmationDialog(
"Delete all \(downloads.count) downloaded videos?",
isPresented: $confirmDeleteAll,
titleVisibility: .visible
) {
Button("Delete All", role: .destructive) {
for download in downloads {
downloadService.delete(videoId: download.videoId, modelContext: modelContext)
}
}
Button("Cancel", role: .cancel) {}
}
}
private func sizeString(_ bytes: Int) -> String {
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
}
}
#Preview {
NavigationStack {
DownloadedVideosView()
}
.modelContainer(for: DownloadedVideo.self, inMemory: true)
}
@@ -3,15 +3,17 @@ import SwiftUI
struct FeatureReferenceView: View { struct FeatureReferenceView: View {
var body: some View { var body: some View {
List { List {
Section("Verb Conjugation Practice") { // MARK: Conjugation
Section("Practice — Conjugation") {
featureRow( featureRow(
icon: "rectangle.stack", color: .blue, icon: "rectangle.stack", color: .blue,
title: "Flashcard / Typing / MC / Handwriting / Sentence Builder", title: "Flashcard / Typing / MC / Handwriting / Sentence Builder",
details: [ details: [
"Pulls from verb conjugation database (1,750 verbs)", "Pulls from the verb conjugation database (1,750 verbs)",
"Filtered by your Level setting", "Filtered by your Level setting",
"Filtered by your Enabled Tenses", "Filtered by your Enabled Tenses",
"Respects Include Vosotros setting", "Respects the Include Vosotros setting",
"Due cards (SRS) shown first, then random", "Due cards (SRS) shown first, then random",
] ]
) )
@@ -21,13 +23,11 @@ struct FeatureReferenceView: View {
title: "Full Table", title: "Full Table",
details: [ details: [
"Shows all 6 person forms for one verb + tense", "Shows all 6 person forms for one verb + tense",
"Random verb from your Level", "Drawn from any regular verb — Level filter is ignored here on purpose, since regular conjugation patterns transfer across vocabulary",
"Random tense from your Enabled Tenses", "Random tense from your Enabled Tenses",
] ]
) )
}
Section("Quick Actions") {
featureRow( featureRow(
icon: "star.fill", color: .orange, icon: "star.fill", color: .orange,
title: "Common Tenses", title: "Common Tenses",
@@ -57,20 +57,86 @@ struct FeatureReferenceView: View {
"Filtered by your Level and Enabled Tenses", "Filtered by your Level and Enabled Tenses",
] ]
) )
}
// MARK: Vocabulary
Section("Practice — Vocabulary") {
featureRow(
icon: "rectangle.on.rectangle.angled", color: .purple,
title: "Vocab Flashcards",
details: [
"English meaning → recall the Spanish verb",
"Pool = verbs at your enabled Levels (the same Level set the Verbs tab filters by)",
"Session size set by Settings → Cards per session",
"Overdue verbs pulled first, then new verbs by frequency",
"Quiz mode: tap to reveal, rate Again/Hard/Good/Easy. Again/Hard requeue the card a few cards later; a second Good or an Easy graduates it. Graduation updates the long-term SRS schedule.",
"Learn mode (toolbar toggle): both sides shown at once, Next/Previous to browse, loops — no rating, no pressure",
"Example sentence generated on-device; speaker button reads the verb aloud",
]
)
featureRow(
icon: "checklist", color: .purple,
title: "Vocab Multiple Choice",
details: [
"English meaning → pick the Spanish verb from 4 options",
"Distractors prefer the same part of speech",
"Same level-filtered pool and SRS session queue as Vocab Flashcards",
]
)
featureRow( featureRow(
icon: "rectangle.stack.fill", color: .teal, icon: "rectangle.stack.fill", color: .teal,
title: "Vocab Review", title: "Vocab Review",
details: [ details: [
"Reviews vocabulary cards that are due (SRS scheduled)", "Reviews course/textbook vocabulary cards that are due (SRS scheduled)",
"Cards become due after you study them in Course quizzes", "Cards become due after you study them in Course quizzes",
"Rate Again/Hard/Good/Easy to schedule next review", "Rate Again/Hard/Good/Easy to schedule the next review",
"Uses all course vocabulary, not filtered by level", "Distinct from Vocab Flashcards — this is course vocab, not the verb table",
] ]
) )
} }
Section("Practice Activities") { // MARK: Reading
Section("Practice — Reading") {
featureRow(
icon: "book.fill", color: .teal,
title: "Stories",
details: [
"AI-generated one-paragraph Spanish stories",
"Matched to your Level and Enabled Tenses",
"Every word is tappable for a definition",
"English translation hidden by default (toggle to reveal)",
"3-question comprehension quiz at the end",
"Requires an Apple Intelligence-capable device",
]
)
featureRow(
icon: "books.vertical.fill", color: .indigo,
title: "Books",
details: [
"Full-length bilingual books bundled with the app",
"Chapter list; read a chapter paragraph by paragraph",
"Tap any word for a definition (offline dictionary, on-device AI fallback)",
"Toggle between Spanish and pre-translated English",
"Read-aloud: a voice reads the chapter with the current word highlighted; tap a word to pause and look it up",
"Voice and speed picker in the read-aloud controls",
]
)
featureRow(
icon: "music.note.list", color: .pink,
title: "Lyrics",
details: [
"Search and save Spanish song lyrics",
"Side-by-side Spanish and English",
"Long-press a word for a definition",
]
)
featureRow( featureRow(
icon: "bubble.left.and.bubble.right.fill", color: .green, icon: "bubble.left.and.bubble.right.fill", color: .green,
title: "Conversation", title: "Conversation",
@@ -79,8 +145,7 @@ struct FeatureReferenceView: View {
"10 scenario types (restaurant, directions, etc.)", "10 scenario types (restaurant, directions, etc.)",
"AI adapts vocabulary to your Level setting", "AI adapts vocabulary to your Level setting",
"Corrections provided inline when you make mistakes", "Corrections provided inline when you make mistakes",
"Conversations saved to iCloud for revisiting", "Requires an Apple Intelligence-capable device",
"Requires Apple Intelligence-capable device",
] ]
) )
@@ -91,8 +156,6 @@ struct FeatureReferenceView: View {
"Listen & Type: hear a sentence, type what you heard", "Listen & Type: hear a sentence, type what you heard",
"Pronunciation: read a sentence aloud, get scored on accuracy", "Pronunciation: read a sentence aloud, get scored on accuracy",
"Sentences pulled from course vocabulary examples", "Sentences pulled from course vocabulary examples",
"Uses all course vocab (not filtered by level)",
"Pronunciation requires microphone permission",
] ]
) )
@@ -101,50 +164,23 @@ struct FeatureReferenceView: View {
title: "Cloze Practice", title: "Cloze Practice",
details: [ details: [
"Fill in the missing word in a Spanish sentence", "Fill in the missing word in a Spanish sentence",
"Sentences from course vocabulary examples",
"4 multiple-choice options (1 correct + 3 distractors)", "4 multiple-choice options (1 correct + 3 distractors)",
"Distractors are other vocabulary words from same pool", "Sentences from course vocabulary examples",
"Uses all course vocab (not filtered by level)",
]
)
featureRow(
icon: "music.note.list", color: .pink,
title: "Lyrics",
details: [
"Search and save Spanish song lyrics",
"Side-by-side Spanish and English translations",
"User-curated library, not filtered by level",
"Saved to iCloud for sync across devices",
]
)
featureRow(
icon: "book.fill", color: .teal,
title: "Stories",
details: [
"AI-generated one-paragraph Spanish stories",
"Matched to your Level and Enabled Tenses",
"Every word is tappable for definition",
"Known words use offline dictionary (175K+ verb forms)",
"Unknown words looked up via on-device AI",
"English translation hidden by default (toggle to reveal)",
"3-question comprehension quiz at the end",
"Saved to iCloud for revisiting",
"Requires Apple Intelligence-capable device",
] ]
) )
} }
// MARK: Guide
Section("Guide") { Section("Guide") {
featureRow( featureRow(
icon: "book", color: .brown, icon: "book", color: .brown,
title: "Tense Guides", title: "Tense Guides",
details: [ details: [
"Detailed explanation of each of the 20 verb tenses", "In-depth guide to each of the 20 verb tenses",
"Conjugation ending tables for -ar, -er, -ir verbs", "Conjugation ending tables, common irregulars, mnemonics",
"Usage patterns with example sentences", "Usage patterns, pitfalls, and contrast with neighbouring tenses",
"Essential tenses marked with orange badge", "Essential tenses marked with an orange badge",
] ]
) )
@@ -152,14 +188,24 @@ struct FeatureReferenceView: View {
icon: "doc.text", color: .brown, icon: "doc.text", color: .brown,
title: "Grammar Notes", title: "Grammar Notes",
details: [ details: [
"23 grammar topics (ser vs estar, por vs para, etc.)", "36 grammar topics (ser vs estar, por vs para, WEIRDO, etc.)",
"Interactive exercises available for 5 topics", "Each with a mnemonic, contrast examples, and common pitfalls",
"Tap 'Practice This' on notes that have exercises", "Interactive exercises available on selected topics",
"Content grouped by category with card-based layout", ]
)
featureRow(
icon: "arrow.triangle.branch", color: .indigo,
title: "Cross-links",
details: [
"Tense guides show \"Related grammar\" chips that jump to the matching grammar note",
"Grammar notes show \"Used in tenses\" chips that jump back",
] ]
) )
} }
// MARK: Course
Section("Course") { Section("Course") {
featureRow( featureRow(
icon: "list.clipboard", color: .orange, icon: "list.clipboard", color: .orange,
@@ -167,11 +213,21 @@ struct FeatureReferenceView: View {
details: [ details: [
"Vocabulary from specific course weeks", "Vocabulary from specific course weeks",
"Multiple quiz types: MC, typing, handwriting, cloze", "Multiple quiz types: MC, typing, handwriting, cloze",
"Focus Area mode for missed words",
"Not filtered by Level (uses course structure)", "Not filtered by Level (uses course structure)",
] ]
) )
featureRow(
icon: "star.circle.fill", color: .yellow,
title: "Extra Study",
details: [
"Star a card during a course flashcard session to mark it for extra study",
"Each week shows an \"Extra Study\" row when it has starred cards",
"Launches a session of just the starred cards for that week",
"Marks are iCloud-synced across devices",
]
)
featureRow( featureRow(
icon: "checkmark.seal", color: .orange, icon: "checkmark.seal", color: .orange,
title: "Checkpoint Exams", title: "Checkpoint Exams",
@@ -183,23 +239,27 @@ struct FeatureReferenceView: View {
) )
} }
// MARK: Dashboard
Section("Dashboard") { Section("Dashboard") {
featureRow( featureRow(
icon: "clock.fill", color: .mint, icon: "clock.fill", color: .mint,
title: "Study Time", title: "Study Time",
details: [ details: [
"Tracks time the app is in the foreground", "Tracks time the app is in the foreground",
"Starts when app becomes active, stops on background", "Shows today's time and an all-time total",
"Shows today's time and all-time total",
"7-day bar chart of daily study time", "7-day bar chart of daily study time",
] ]
) )
} }
// MARK: Settings
Section("Settings That Affect Practice") { Section("Settings That Affect Practice") {
settingRow(name: "Level", affects: "Verb practice, Full Table, Quick Actions, Stories, Conversation") settingRow(name: "Level", affects: "Conjugation practice, Vocab Flashcards & Multiple Choice, Stories, Conversation. Shared with the Verbs tab filter. Full Table ignores level.")
settingRow(name: "Enabled Tenses", affects: "Verb practice, Full Table, Irregularity Drills, Stories") settingRow(name: "Enabled Tenses", affects: "Conjugation practice, Full Table, Irregularity Drills, Stories")
settingRow(name: "Include Vosotros", affects: "Verb practice, Full Table, Quick Actions") settingRow(name: "Include Vosotros", affects: "Conjugation practice, Full Table, Common Tenses")
settingRow(name: "Cards per session", affects: "How many verbs a Vocab Flashcards / Multiple Choice session draws")
settingRow(name: "Daily Goal", affects: "Dashboard progress tracking only") settingRow(name: "Daily Goal", affects: "Dashboard progress tracking only")
} }
} }
@@ -9,9 +9,15 @@ struct SettingsView: View {
@State private var dailyGoal: Double = 50 @State private var dailyGoal: Double = 50
@State private var showVosotros: Bool = true @State private var showVosotros: Bool = true
@State private var autoFillStem: Bool = false @State private var autoFillStem: Bool = false
@State private var selectedLevel: VerbLevel = .basic
/// Cards per vocab-practice session. 999 = "All" (no cap).
@AppStorage("vocabSessionCardLimit") private var vocabSessionCardLimit: Int = 20
private let vocabSessionSizes: [Int] = [10, 15, 20, 25, 30, 50, 999]
private let levels = VerbLevel.allCases private let levels = VerbLevel.allCases
private let irregularCategories: [IrregularSpan.SpanCategory] = [
.spelling, .stemChange, .uniqueIrregular
]
private var cloudModelContext: ModelContext { cloudModelContextProvider() } private var cloudModelContext: ModelContext { cloudModelContextProvider() }
var body: some View { var body: some View {
@@ -40,19 +46,38 @@ struct SettingsView: View {
} }
} }
Section("Level") { Section {
Picker("Current Level", selection: $selectedLevel) { Picker("Cards per session", selection: $vocabSessionCardLimit) {
ForEach(levels, id: \.self) { level in ForEach(vocabSessionSizes, id: \.self) { size in
Text(level.displayName).tag(level) Text(size == 999 ? "All" : "\(size)").tag(size)
} }
} }
.onChange(of: selectedLevel) { _, newValue in } header: {
progress?.selectedVerbLevel = newValue Text("Vocab Flashcards")
saveProgress() } footer: {
} Text("How many verbs a Vocab Flashcards session draws. Overdue verbs are pulled first, then new ones.")
} }
Section("Tenses") { Section {
ForEach(levels, id: \.self) { level in
Toggle(level.displayName, isOn: Binding(
get: {
progress?.selectedVerbLevels.contains(level) ?? false
},
set: { enabled in
guard let progress else { return }
progress.setLevelEnabled(level, enabled: enabled)
saveProgress()
}
))
}
} header: {
Text("Levels")
} footer: {
Text("Practice pulls only from verbs whose level is enabled. Turn on multiple to mix.")
}
Section {
ForEach(TenseInfo.all) { tense in ForEach(TenseInfo.all) { tense in
Toggle(tense.english, isOn: Binding( Toggle(tense.english, isOn: Binding(
get: { get: {
@@ -65,6 +90,41 @@ struct SettingsView: View {
} }
)) ))
} }
} header: {
Text("Tenses")
}
Section {
ForEach(irregularCategories, id: \.self) { category in
Toggle(category.rawValue, isOn: Binding(
get: {
progress?.enabledIrregularCategories.contains(category) ?? false
},
set: { enabled in
guard let progress else { return }
progress.setIrregularCategoryEnabled(category, enabled: enabled)
saveProgress()
}
))
}
} header: {
Text("Irregular Types")
} footer: {
Text("Leave all off to include regular and irregular verbs. Enable any to restrict practice to those irregularity types.")
}
Section {
Toggle("Reflexive verbs only", isOn: Binding(
get: { progress?.showReflexiveVerbsOnly ?? false },
set: { enabled in
progress?.showReflexiveVerbsOnly = enabled
saveProgress()
}
))
} header: {
Text("Reflexive")
} footer: {
Text("When on, practice pulls only from the curated list of common reflexive verbs.")
} }
Section("Stats") { Section("Stats") {
@@ -79,6 +139,9 @@ struct SettingsView: View {
NavigationLink("How Features Work") { NavigationLink("How Features Work") {
FeatureReferenceView() FeatureReferenceView()
} }
NavigationLink("Downloaded Videos") {
DownloadedVideosView()
}
} }
Section("About") { Section("About") {
@@ -96,7 +159,6 @@ struct SettingsView: View {
dailyGoal = Double(resolved.dailyGoal) dailyGoal = Double(resolved.dailyGoal)
showVosotros = resolved.showVosotros showVosotros = resolved.showVosotros
autoFillStem = resolved.autoFillStem autoFillStem = resolved.autoFillStem
selectedLevel = resolved.selectedVerbLevel
} }
private func saveProgress() { private func saveProgress() {
@@ -4,14 +4,40 @@ import SwiftData
struct VerbDetailView: View { struct VerbDetailView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(VerbExampleCache.self) private var exampleCache
@Environment(ReflexiveVerbStore.self) private var reflexiveStore
@State private var speechService = SpeechService() @State private var speechService = SpeechService()
let verb: Verb let verb: Verb
@State private var selectedTense: TenseInfo = TenseInfo.all[0] @State private var selectedTense: TenseInfo = TenseInfo.all[0]
@State private var examples: [VerbExample] = []
@State private var examplesState: ExamplesState = .idle
private enum ExamplesState: Equatable {
case idle
case loading
case loaded
case unavailable
case failed(String)
}
private static let exampleTenseIds: [String] = [
TenseID.ind_presente.rawValue,
TenseID.ind_preterito.rawValue,
TenseID.ind_imperfecto.rawValue,
TenseID.ind_futuro.rawValue,
TenseID.subj_presente.rawValue,
TenseID.imp_afirmativo.rawValue,
]
private var formsForTense: [VerbForm] { private var formsForTense: [VerbForm] {
ReferenceStore(context: modelContext).fetchForms(verbId: verb.id, tenseId: selectedTense.id) ReferenceStore(context: modelContext).fetchForms(verbId: verb.id, tenseId: selectedTense.id)
} }
private var reflexiveEntries: [ReflexiveVerb] {
reflexiveStore.entries(for: verb.infinitive)
}
var body: some View { var body: some View {
List { List {
Section { Section {
@@ -25,6 +51,10 @@ struct VerbDetailView: View {
Text("Info") Text("Info")
} }
if !reflexiveEntries.isEmpty {
reflexiveSection
}
Section { Section {
Picker("Tense", selection: $selectedTense) { Picker("Tense", selection: $selectedTense) {
ForEach(TenseInfo.all) { tense in ForEach(TenseInfo.all) { tense in
@@ -66,6 +96,8 @@ struct VerbDetailView: View {
} header: { } header: {
Text("Conjugation") Text("Conjugation")
} }
examplesSection
} }
.navigationTitle(verb.infinitive) .navigationTitle(verb.infinitive)
.toolbar { .toolbar {
@@ -78,6 +110,129 @@ struct VerbDetailView: View {
.tint(.secondary) .tint(.secondary)
} }
} }
.task(id: verb.id) {
await loadExamples()
}
}
// MARK: - Reflexive
private var reflexiveSection: some View {
Section {
ForEach(Array(reflexiveEntries.enumerated()), id: \.offset) { _, entry in
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(entry.infinitive)
.font(.body.weight(.semibold))
.italic()
if let hint = entry.usageHint, !hint.isEmpty {
Text(hint)
.font(.caption.weight(.medium))
.foregroundStyle(.tint)
}
}
Text(entry.english)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
} header: {
Text("Reflexive")
} footer: {
if reflexiveEntries.contains(where: { $0.usageHint != nil }) {
Text("Highlighted words are prepositions or phrases this verb commonly pairs with.")
.font(.caption2)
}
}
}
// MARK: - Examples
@ViewBuilder
private var examplesSection: some View {
Section {
switch examplesState {
case .idle, .loading:
HStack(spacing: 10) {
ProgressView()
Text("Generating examples…")
.font(.caption)
.foregroundStyle(.secondary)
}
case .unavailable:
Label("Examples require Apple Intelligence on this device.", systemImage: "sparkles")
.font(.caption)
.foregroundStyle(.secondary)
case .failed(let message):
Label(message, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.secondary)
case .loaded:
if examples.isEmpty {
Label("No examples available.", systemImage: "text.quote")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(Array(examples.enumerated()), id: \.offset) { _, example in
VStack(alignment: .leading, spacing: 4) {
if let info = TenseInfo.find(example.tenseId) {
Text(info.english)
.font(.caption2.weight(.semibold))
.foregroundStyle(.tint)
}
Text(example.spanish)
.font(.body)
.italic()
Text(example.english)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
}
}
} header: {
Text("Examples")
}
}
private func loadExamples() async {
// Reset state when navigating between verbs via NavigationSplitView.
examples = []
examplesState = .idle
if let cached = exampleCache.examples(for: verb.id), !cached.isEmpty {
examples = cached
examplesState = .loaded
return
}
guard VerbExampleGenerator.isAvailable else {
examplesState = .unavailable
return
}
examplesState = .loading
do {
let formsByTense = ReferenceStore(context: modelContext)
.conjugatedForms(verbId: verb.id, tenseIds: Self.exampleTenseIds)
let generated = try await VerbExampleGenerator.generate(
verbInfinitive: verb.infinitive,
verbEnglish: verb.english,
tenseIds: Self.exampleTenseIds,
formsByTense: formsByTense
)
guard !generated.isEmpty else {
examplesState = .failed("Could not generate examples.")
return
}
exampleCache.setExamples(generated, for: verb.id)
examples = generated
examplesState = .loaded
} catch {
examplesState = .failed("Could not generate examples.")
}
} }
} }
@@ -86,4 +241,6 @@ struct VerbDetailView: View {
VerbDetailView(verb: Verb(id: 1, infinitive: "hablar", english: "to speak", rank: 1, ending: "ar", reflexive: 0, level: "basic")) VerbDetailView(verb: Verb(id: 1, infinitive: "hablar", english: "to speak", rank: 1, ending: "ar", reflexive: 0, level: "basic"))
} }
.modelContainer(for: [Verb.self, VerbForm.self], inMemory: true) .modelContainer(for: [Verb.self, VerbForm.self], inMemory: true)
.environment(VerbExampleCache())
.environment(ReflexiveVerbStore())
} }
+241 -20
View File
@@ -2,17 +2,67 @@ import SwiftUI
import SwiftData import SwiftData
import SharedModels import SharedModels
enum IrregularityCategory: String, CaseIterable, Identifiable {
case anyIrregular = "Any Irregular"
case spelling = "Spelling Change"
case stemChange = "Stem Change"
case uniqueIrregular = "Unique Irregular"
var id: String { rawValue }
var systemImage: String {
switch self {
case .anyIrregular: "asterisk"
case .spelling: "character.cursor.ibeam"
case .stemChange: "arrow.triangle.2.circlepath"
case .uniqueIrregular: "star"
}
}
}
struct VerbListView: View { struct VerbListView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(ReflexiveVerbStore.self) private var reflexiveStore
@State private var verbs: [Verb] = [] @State private var verbs: [Verb] = []
@State private var irregularityByVerbId: [Int: Set<IrregularityCategory>] = [:]
@State private var searchText = "" @State private var searchText = ""
@State private var selectedLevel: String? @State private var progress: UserProgress?
@State private var selectedIrregularity: IrregularityCategory?
@State private var reflexiveOnly: Bool = false
@State private var selectedVerb: Verb? @State private var selectedVerb: Verb?
private var cloudContext: ModelContext { cloudModelContextProvider() }
/// Levels currently enabled in `UserProgress` the same set that drives
/// what Practice picks from. The Verbs tab reads and writes the same
/// state so changes here propagate to Practice and vice versa.
private var selectedLevels: Set<VerbLevel> {
progress?.selectedVerbLevels ?? []
}
/// True when the user has every available level enabled (or none, which
/// we treat as "no filter applied" on the Verbs list specifically).
private var allLevelsActive: Bool {
selectedLevels.isEmpty || selectedLevels.count == VerbLevel.allCases.count
}
private var filteredVerbs: [Verb] { private var filteredVerbs: [Verb] {
var result = verbs var result = verbs
if let level = selectedLevel { if !allLevelsActive {
result = result.filter { VerbLevelGroup.matches($0.level, selectedLevel: level) } let activeRaws = Set(selectedLevels.map(\.rawValue))
result = result.filter { verb in
activeRaws.contains { VerbLevelGroup.matches(verb.level, selectedLevel: $0) }
}
}
if let category = selectedIrregularity {
result = result.filter { verb in
guard let cats = irregularityByVerbId[verb.id] else { return false }
return category == .anyIrregular ? !cats.isEmpty : cats.contains(category)
}
}
if reflexiveOnly {
result = result.filter { reflexiveStore.isReflexive(baseInfinitive: $0.infinitive) }
} }
if !searchText.isEmpty { if !searchText.isEmpty {
let query = searchText.lowercased() let query = searchText.lowercased()
@@ -24,31 +74,75 @@ struct VerbListView: View {
return result return result
} }
private let levels = ["basic", "elementary", "intermediate", "advanced", "expert"] private let levels: [VerbLevel] = VerbLevel.allCases
var body: some View { var body: some View {
NavigationSplitView { NavigationSplitView {
List(filteredVerbs, selection: $selectedVerb) { verb in List(filteredVerbs, selection: $selectedVerb) { verb in
NavigationLink(value: verb) { NavigationLink(value: verb) {
VerbRowView(verb: verb) VerbRowView(verb: verb, irregularities: irregularityByVerbId[verb.id] ?? [])
} }
} }
.navigationTitle("Verbs") .navigationTitle("Verbs")
.searchable(text: $searchText, prompt: "Search verbs...") .searchable(text: $searchText, prompt: "Search verbs...")
.safeAreaInset(edge: .top, spacing: 0) {
if hasActiveFilter {
activeFilterBar
}
}
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Menu { Menu {
Button("All Levels") { selectedLevel = nil } Section("Level") {
ForEach(levels, id: \.self) { level in Button {
Button(level.capitalized) { selectedLevel = level } setAllLevels(enabled: true)
} label: {
Label("All Levels", systemImage: allLevelsActive ? "checkmark" : "circle")
}
ForEach(levels, id: \.self) { level in
Button {
toggleLevel(level)
} label: {
Label(level.displayName, systemImage: selectedLevels.contains(level) ? "checkmark" : "circle")
}
}
}
Section("Irregularity") {
Button {
selectedIrregularity = nil
} label: {
Label("All Verbs", systemImage: selectedIrregularity == nil ? "checkmark" : "circle")
}
ForEach(IrregularityCategory.allCases) { category in
Button {
selectedIrregularity = category
} label: {
Label(category.rawValue, systemImage: selectedIrregularity == category ? "checkmark" : category.systemImage)
}
}
}
Section("Reflexive") {
Button {
reflexiveOnly.toggle()
} label: {
Label("Reflexive verbs only", systemImage: reflexiveOnly ? "checkmark" : "arrow.triangle.2.circlepath")
}
} }
} label: { } label: {
Label("Filter", systemImage: "line.3.horizontal.decrease.circle") Label("Filter", systemImage: hasActiveFilter ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
} }
} }
} }
.task { loadVerbs() } .task {
.onAppear { loadVerbs() } loadVerbs()
loadProgress()
}
.onAppear {
loadVerbs()
loadProgress()
}
} detail: { } detail: {
if let verb = selectedVerb { if let verb = selectedVerb {
VerbDetailView(verb: verb) VerbDetailView(verb: verb)
@@ -58,6 +152,56 @@ struct VerbListView: View {
} }
} }
private var hasActiveFilter: Bool {
!allLevelsActive || selectedIrregularity != nil || reflexiveOnly
}
@ViewBuilder
private var activeFilterBar: some View {
HStack(spacing: 8) {
if !allLevelsActive {
filterChip(text: levelChipLabel, systemImage: "graduationcap") {
setAllLevels(enabled: true)
}
}
if let cat = selectedIrregularity {
filterChip(text: cat.rawValue, systemImage: cat.systemImage) {
selectedIrregularity = nil
}
}
if reflexiveOnly {
filterChip(text: "Reflexive", systemImage: "arrow.triangle.2.circlepath") {
reflexiveOnly = false
}
}
Spacer()
Text("\(filteredVerbs.count)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(.bar)
}
private func filterChip(text: String, systemImage: String, onClear: @escaping () -> Void) -> some View {
Button(action: onClear) {
HStack(spacing: 4) {
Image(systemName: systemImage)
.font(.caption2)
Text(text)
.font(.caption.weight(.medium))
Image(systemName: "xmark")
.font(.caption2)
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(.blue.opacity(0.15), in: Capsule())
.foregroundStyle(.blue)
}
.buttonStyle(.plain)
}
private func loadVerbs() { private func loadVerbs() {
// Hit the shared local container directly, bypassing @Environment. // Hit the shared local container directly, bypassing @Environment.
guard let container = SharedStore.localContainer else { guard let container = SharedStore.localContainer else {
@@ -69,12 +213,64 @@ struct VerbListView: View {
} }
let context = ModelContext(container) let context = ModelContext(container)
verbs = ReferenceStore(context: context).fetchVerbs() verbs = ReferenceStore(context: context).fetchVerbs()
print("[VerbListView] loaded \(verbs.count) verbs (container: \(ObjectIdentifier(container)))") irregularityByVerbId = buildIrregularityIndex(context: context)
print("[VerbListView] loaded \(verbs.count) verbs, \(irregularityByVerbId.count) flagged irregular (container: \(ObjectIdentifier(container)))")
}
// MARK: - Level filter (shared with Practice via UserProgress)
private var levelChipLabel: String {
let names = selectedLevels
.sorted { $0.rawValue < $1.rawValue }
.map(\.displayName)
if names.isEmpty { return "No levels" }
if names.count == 1 { return names[0] }
return "\(names.count) levels"
}
private func loadProgress() {
progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
}
private func toggleLevel(_ level: VerbLevel) {
guard let progress else { return }
let enabled = !progress.selectedVerbLevels.contains(level)
progress.setLevelEnabled(level, enabled: enabled)
try? cloudContext.save()
}
private func setAllLevels(enabled: Bool) {
guard let progress else { return }
if enabled {
progress.selectedVerbLevels = Set(VerbLevel.allCases)
} else {
// Practice treats an empty set as "no verbs", so guard against
// leaving the user with nothing keep at least `basic`.
progress.selectedVerbLevels = [.basic]
}
try? cloudContext.save()
}
private func buildIrregularityIndex(context: ModelContext) -> [Int: Set<IrregularityCategory>] {
let spans = (try? context.fetch(FetchDescriptor<IrregularSpan>())) ?? []
var index: [Int: Set<IrregularityCategory>] = [:]
for span in spans {
let category: IrregularityCategory
switch span.spanType {
case 100..<200: category = .spelling
case 200..<300: category = .stemChange
case 300..<400: category = .uniqueIrregular
default: continue
}
index[span.verbId, default: []].insert(category)
}
return index
} }
} }
struct VerbRowView: View { struct VerbRowView: View {
let verb: Verb let verb: Verb
var irregularities: Set<IrregularityCategory> = []
var body: some View { var body: some View {
HStack { HStack {
@@ -88,14 +284,39 @@ struct VerbRowView: View {
Spacer() Spacer()
Text(verb.level.prefix(3).uppercased()) HStack(spacing: 4) {
.font(.caption2) ForEach(orderedIrregularities, id: \.self) { cat in
.fontWeight(.semibold) Image(systemName: cat.systemImage)
.padding(.horizontal, 8) .font(.caption2.weight(.semibold))
.padding(.vertical, 4) .foregroundStyle(irregularityColor(cat))
.background(levelColor(verb.level).opacity(0.15)) .help(cat.rawValue)
.foregroundStyle(levelColor(verb.level)) .accessibilityLabel(cat.rawValue)
.clipShape(Capsule()) }
Text(verb.level.prefix(3).uppercased())
.font(.caption2)
.fontWeight(.semibold)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(levelColor(verb.level).opacity(0.15))
.foregroundStyle(levelColor(verb.level))
.clipShape(Capsule())
}
}
}
private var orderedIrregularities: [IrregularityCategory] {
// Order: unique > stem > spelling (most notable first)
let order: [IrregularityCategory] = [.uniqueIrregular, .stemChange, .spelling]
return order.filter { irregularities.contains($0) }
}
private func irregularityColor(_ category: IrregularityCategory) -> Color {
switch category {
case .uniqueIrregular: return .purple
case .stemChange: return .orange
case .spelling: return .teal
case .anyIrregular: return .gray
} }
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+104
View File
@@ -0,0 +1,104 @@
[
{"infinitive": "aburrirse", "baseInfinitive": "aburrir", "english": "to get bored"},
{"infinitive": "acercarse", "baseInfinitive": "acercar", "english": "to get close to", "usageHint": "a"},
{"infinitive": "acordarse", "baseInfinitive": "acordar", "english": "to remember", "usageHint": "de"},
{"infinitive": "acostarse", "baseInfinitive": "acostar", "english": "to lay down / to go to bed"},
{"infinitive": "acostumbrarse", "baseInfinitive": "acostumbrar", "english": "to get used to", "usageHint": "a"},
{"infinitive": "afeitarse", "baseInfinitive": "afeitar", "english": "to shave"},
{"infinitive": "alegrarse", "baseInfinitive": "alegrar", "english": "to be glad / happy / pleased"},
{"infinitive": "alejarse", "baseInfinitive": "alejar", "english": "to get away from", "usageHint": "de"},
{"infinitive": "animarse", "baseInfinitive": "animar", "english": "to cheer up / to dare to do something", "usageHint": "a"},
{"infinitive": "apurarse", "baseInfinitive": "apurar", "english": "to hurry"},
{"infinitive": "aprovecharse", "baseInfinitive": "aprovechar", "english": "to take advantage of", "usageHint": "de"},
{"infinitive": "asustarse", "baseInfinitive": "asustar", "english": "to get or become afraid"},
{"infinitive": "atreverse", "baseInfinitive": "atrever", "english": "to dare to", "usageHint": "a"},
{"infinitive": "bañarse", "baseInfinitive": "bañar", "english": "to take a bath / shower"},
{"infinitive": "burlarse", "baseInfinitive": "burlar", "english": "to make fun of", "usageHint": "de"},
{"infinitive": "caerse", "baseInfinitive": "caer", "english": "to fall down"},
{"infinitive": "calmarse", "baseInfinitive": "calmar", "english": "to calm down"},
{"infinitive": "cansarse", "baseInfinitive": "cansar", "english": "to get tired (of)", "usageHint": "(de)"},
{"infinitive": "casarse", "baseInfinitive": "casar", "english": "to marry", "usageHint": "con"},
{"infinitive": "cepillarse", "baseInfinitive": "cepillar", "english": "to brush (hair, teeth)"},
{"infinitive": "deprimirse", "baseInfinitive": "deprimir", "english": "to become depressed"},
{"infinitive": "conformarse", "baseInfinitive": "conformar", "english": "to resign oneself to", "usageHint": "con"},
{"infinitive": "volverse", "baseInfinitive": "volver", "english": "to become / to turn into / to return"},
{"infinitive": "darse", "baseInfinitive": "dar", "english": "to realize", "usageHint": "cuenta de"},
{"infinitive": "dedicarse", "baseInfinitive": "dedicar", "english": "to dedicate oneself to / to do for a living", "usageHint": "a"},
{"infinitive": "despedirse", "baseInfinitive": "despedir", "english": "to say goodbye", "usageHint": "(de)"},
{"infinitive": "despertarse", "baseInfinitive": "despertar", "english": "to wake up"},
{"infinitive": "desvestirse", "baseInfinitive": "desvestir", "english": "to undress"},
{"infinitive": "dirigirse", "baseInfinitive": "dirigir", "english": "to go to / make one's way toward / to address", "usageHint": "a"},
{"infinitive": "hacerse", "baseInfinitive": "hacer", "english": "to become / to pretend"},
{"infinitive": "divertirse", "baseInfinitive": "divertir", "english": "to have fun"},
{"infinitive": "dormirse", "baseInfinitive": "dormir", "english": "to fall asleep / to oversleep"},
{"infinitive": "ducharse", "baseInfinitive": "duchar", "english": "to shower"},
{"infinitive": "echarse", "baseInfinitive": "echar", "english": "to begin (usually suddenly) to do something / to break into", "usageHint": "a"},
{"infinitive": "enamorarse", "baseInfinitive": "enamorar", "english": "to fall in love with", "usageHint": "de"},
{"infinitive": "encargarse", "baseInfinitive": "encargar", "english": "to take charge of or be responsible for", "usageHint": "de"},
{"infinitive": "encogerse", "baseInfinitive": "encoger", "english": "to shrug (shoulders)", "usageHint": "(de hombros)"},
{"infinitive": "encontrarse", "baseInfinitive": "encontrar", "english": "to meet with / to run into someone", "usageHint": "(con)"},
{"infinitive": "enfermarse", "baseInfinitive": "enfermar", "english": "to get sick"},
{"infinitive": "enojarse", "baseInfinitive": "enojar", "english": "to get or become angry"},
{"infinitive": "enterarse", "baseInfinitive": "enterar", "english": "to find out, to realize", "usageHint": "de"},
{"infinitive": "exponerse", "baseInfinitive": "exponer", "english": "to expose oneself to or run the risk of", "usageHint": "a"},
{"infinitive": "fijarse", "baseInfinitive": "fijar", "english": "to pay attention to / to take a look"},
{"infinitive": "jugarse", "baseInfinitive": "jugar", "english": "to risk"},
{"infinitive": "lastimarse", "baseInfinitive": "lastimar", "english": "to get hurt or hurt oneself"},
{"infinitive": "lavarse", "baseInfinitive": "lavar", "english": "to wash (a body part)"},
{"infinitive": "levantarse", "baseInfinitive": "levantar", "english": "to get up"},
{"infinitive": "maquillarse", "baseInfinitive": "maquillar", "english": "to put makeup on"},
{"infinitive": "meterse", "baseInfinitive": "meter", "english": "to get into / to pick on / to pick a fight with", "usageHint": "en / con"},
{"infinitive": "motivarse", "baseInfinitive": "motivar", "english": "to become or get motivated to"},
{"infinitive": "moverse", "baseInfinitive": "mover", "english": "to move oneself"},
{"infinitive": "mudarse", "baseInfinitive": "mudar", "english": "to move (change residence)"},
{"infinitive": "negarse", "baseInfinitive": "negar", "english": "to refuse to", "usageHint": "a"},
{"infinitive": "obsesionarse", "baseInfinitive": "obsesionar", "english": "to be or get obsessed with", "usageHint": "con"},
{"infinitive": "ocuparse", "baseInfinitive": "ocupar", "english": "to look after", "usageHint": "de"},
{"infinitive": "olvidarse", "baseInfinitive": "olvidar", "english": "to forget", "usageHint": "de"},
{"infinitive": "parecerse", "baseInfinitive": "parecer", "english": "to look like someone or something", "usageHint": "a"},
{"infinitive": "peinarse", "baseInfinitive": "peinar", "english": "to comb your hair"},
{"infinitive": "ponerse", "baseInfinitive": "poner", "english": "to put on (clothing) / to get or become"},
{"infinitive": "ponerse", "baseInfinitive": "poner", "english": "to come to an agreement with someone", "usageHint": "de acuerdo"},
{"infinitive": "preocuparse", "baseInfinitive": "preocupar", "english": "to worry about", "usageHint": "por"},
{"infinitive": "prepararse", "baseInfinitive": "preparar", "english": "to prepare to"},
{"infinitive": "probarse", "baseInfinitive": "probar", "english": "to try on"},
{"infinitive": "quebrarse", "baseInfinitive": "quebrar", "english": "to break (an arm, leg, etc.)"},
{"infinitive": "quejarse", "baseInfinitive": "quejar", "english": "to complain about", "usageHint": "de"},
{"infinitive": "quedarse", "baseInfinitive": "quedar", "english": "to remain / to stay"},
{"infinitive": "quemarse", "baseInfinitive": "quemar", "english": "to burn oneself / one's body"},
{"infinitive": "quitarse", "baseInfinitive": "quitar", "english": "to take off (clothing, etc.)"},
{"infinitive": "reírse", "baseInfinitive": "reír", "english": "to laugh about", "usageHint": "de"},
{"infinitive": "resignarse", "baseInfinitive": "resignar", "english": "to resign oneself to", "usageHint": "a"},
{"infinitive": "romperse", "baseInfinitive": "romper", "english": "to break (an arm, leg, etc.)"},
{"infinitive": "secarse", "baseInfinitive": "secar", "english": "to dry (a body part)"},
{"infinitive": "sentarse", "baseInfinitive": "sentar", "english": "to sit down"},
{"infinitive": "sentirse", "baseInfinitive": "sentir", "english": "to feel"},
{"infinitive": "servirse", "baseInfinitive": "servir", "english": "to help oneself to (food)"},
{"infinitive": "suicidarse", "baseInfinitive": "suicidar", "english": "to commit suicide"},
{"infinitive": "tratarse", "baseInfinitive": "tratar", "english": "to be about", "usageHint": "de"},
{"infinitive": "vestirse", "baseInfinitive": "vestir", "english": "to get dressed"},
{"infinitive": "marearse", "baseInfinitive": "marear", "english": "to get sick, to get dizzy"},
{"infinitive": "irse", "baseInfinitive": "ir", "english": "to leave"},
{"infinitive": "imaginarse", "baseInfinitive": "imaginar", "english": "to imagine"},
{"infinitive": "preguntarse", "baseInfinitive": "preguntar", "english": "to wonder"},
{"infinitive": "llamarse", "baseInfinitive": "llamar", "english": "to be called"},
{"infinitive": "verse", "baseInfinitive": "ver", "english": "to look or appear"},
{"infinitive": "distraerse", "baseInfinitive": "distraer", "english": "to get distracted"},
{"infinitive": "concentrarse", "baseInfinitive": "concentrar", "english": "to focus"},
{"infinitive": "rendirse", "baseInfinitive": "rendir", "english": "to give up"},
{"infinitive": "relajarse", "baseInfinitive": "relajar", "english": "to relax"},
{"infinitive": "merecerse", "baseInfinitive": "merecer", "english": "to deserve"},
{"infinitive": "suponerse", "baseInfinitive": "suponer", "english": "to suppose"},
{"infinitive": "conectarse", "baseInfinitive": "conectar", "english": "to connect"},
{"infinitive": "destacarse", "baseInfinitive": "destacar", "english": "to stand out"},
{"infinitive": "recibirse", "baseInfinitive": "recibir", "english": "to graduate"},
{"infinitive": "graduarse", "baseInfinitive": "graduar", "english": "to graduate"},
{"infinitive": "perderse", "baseInfinitive": "perder", "english": "to get lost"},
{"infinitive": "cambiarse", "baseInfinitive": "cambiar", "english": "to change (clothing)", "usageHint": "(de ropa)"},
{"infinitive": "adaptarse", "baseInfinitive": "adaptar", "english": "to adapt, to adjust", "usageHint": "a"},
{"infinitive": "salirse", "baseInfinitive": "salir", "english": "to get away with", "usageHint": "con (la suya)"},
{"infinitive": "subirse", "baseInfinitive": "subir", "english": "to get on (the bus, etc.)", "usageHint": "a"},
{"infinitive": "tranquilizarse", "baseInfinitive": "tranquilizar", "english": "to relax"},
{"infinitive": "equivocarse", "baseInfinitive": "equivocar", "english": "to get something wrong / confused"},
{"infinitive": "confundirse", "baseInfinitive": "confundir", "english": "to get something wrong / confused"}
]
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
{
"note": "Curated YouTube videos per guide/grammar item for Issue #21. Each entry: {videoId, title}. Missing entries surface a 'No video yet' label in the app. TheLanguageTutor videos preferred where a matching lesson exists.",
"tenseGuides": {
"ind_presente": {"videoId": "8HWXJjxvOTE", "title": "Spanish Present Tense: Regular -AR -ER -IR verb conjugation"},
"ind_preterito": {"videoId": "0PR7G81gKEc", "title": "Past Tense Verbs You Must Learn in Spanish — The Language Tutor Lesson 64"},
"ind_imperfecto": {"videoId": "AmnTX30VliE", "title": "Mastering the Imperfect Tense in Spanish — The Language Tutor Lesson 50"},
"ind_futuro": {"videoId": "U42loE1zhdw", "title": "Spanish Verbs in Future Tense — The Language Tutor Lesson 51"},
"ind_perfecto": {"videoId": "-uHwV5Lu310", "title": "Present Perfect Indicative Tense in Spanish — The Language Tutor Lesson 55"},
"ind_pluscuamperfecto": {"videoId": "T_M3h88BUUw", "title": "Understanding Past Perfect Indicative Tense in Spanish — The Language Tutor Lesson 57"},
"ind_futuro_perfecto": {"videoId": "6cgc5ENNbR4", "title": "Understanding the Future Perfect Indicative in Spanish — The Language Tutor Lesson 62"},
"ind_preterito_anterior": {"videoId": "OCCgeYLlqck", "title": "The Past Anterior Tense in Spanish — The Language Tutor Lesson 65"},
"cond_presente": {"videoId": "nRaMf1Y1TCM", "title": "Understanding the Conditional Tense in Spanish — The Language Tutor Lesson 52"},
"cond_perfecto": {"videoId": "nlF-8kg-xaM", "title": "Mastering the Conditional Perfect Tense in Spanish — The Language Tutor Lesson 63"},
"subj_presente": {"videoId": "CRvXpo45oHw", "title": "The Subjunctive in Spanish — The Language Tutor Lesson 58"},
"subj_imperfecto_1": {"videoId": "5VkCYZGvNlI", "title": "Imperfect Subjunctive in Spanish — The Language Tutor Lesson 59"},
"subj_imperfecto_2": {"videoId": "5VkCYZGvNlI", "title": "Imperfect Subjunctive in Spanish — The Language Tutor Lesson 59"},
"subj_perfecto": {"videoId": "Sm2DDq99Uzw", "title": "Unlock Fluent Spanish: Present Perfect Subjunctive Mastery! — The Language Tutor"},
"subj_pluscuamperfecto_1": {"videoId": "am0YiYkTQ_E", "title": "Understanding the Past Perfect Subjunctive in Spanish — The Language Tutor Lesson 61"},
"subj_pluscuamperfecto_2": {"videoId": "am0YiYkTQ_E", "title": "Understanding the Past Perfect Subjunctive in Spanish — The Language Tutor Lesson 61"},
"subj_futuro": {"videoId": "YPWJsmD3hN4", "title": "Spanish Answers, Episode 10: Future Subjunctive"},
"subj_futuro_perfecto": {"videoId": "9vmo2C-0iuQ", "title": "Free Spanish Lessons 151 - Spanish Subjunctive Tense: Future Perfect"},
"imp_afirmativo": {"videoId": "C2UnO5khpi4", "title": "How to Make Commands in Spanish — The Language Tutor Lesson 54"},
"imp_negativo": {"videoId": "C2UnO5khpi4", "title": "How to Make Commands in Spanish — The Language Tutor Lesson 54"}
},
"grammarNotes": {
"ser-vs-estar": {"videoId": "tFXGCUi2yls", "title": "Ser vs Estar — BaseLang Spanish Lesson #3"},
"por-vs-para": {"videoId": "02Am4LlPGjU", "title": "When to Use Por or Para in Spanish — The Language Tutor Lesson 47"},
"preterite-vs-imperfect": {"videoId": "LvhO2-azzig", "title": "Spanish Past Tense: When to Use the Preterite vs Imperfect — BaseLang"},
"subjunctive-triggers": {"videoId": "pMAMDgNNPB0", "title": "Spanish Subjunctive Simplified For Beginners — BaseLang"},
"reflexive-verbs": {"videoId": "_uH_tosBLyo", "title": "Reflexive Verbs in Spanish — The Language Tutor Lesson 37"},
"object-pronouns": {"videoId": "gEe4NW1FXx4", "title": "Intro To Spanish Direct And Indirect Object Pronouns — BaseLang"},
"gustar-like-verbs": {"videoId": "SAfXpyZlz-I", "title": "How to Use Gustar in Spanish — The Language Tutor Lesson 121"},
"comparatives-superlatives": {"videoId": "U74ClJsbfb0", "title": "Comparatives and Superlatives in Spanish — The Language Tutor Lesson 123"},
"conditional-if-clauses": {"videoId": "thvW8qVsqkE", "title": "Si Clauses: The Spanish Hypothetical Explained — BaseLang"},
"commands-imperative": {"videoId": "C2UnO5khpi4", "title": "How to Make Commands in Spanish — The Language Tutor Lesson 54"},
"saber-vs-conocer": {"videoId": "j87i7MVCvIE", "title": "Saber vs. Conocer: Right (and WRONG) Times to Use These Spanish Verbs"},
"double-negatives": {"videoId": "Y887wmI0O_o", "title": "Understanding Negation Words in Spanish — The Language Tutor Lesson 67"},
"adjective-placement": {"videoId": "2B_TK_aun8E", "title": "Adjectives and Nouns Working Together in Spanish — The Language Tutor Lesson 66"},
"tener-expressions": {"videoId": "189Qg68VCmo", "title": "The Verb Tener — BaseLang Spanish Lesson #17"},
"personal-a": {"videoId": "B38CwLxgmOc", "title": "Learn Spanish — Preposition 'A' vs. Personal 'A'"},
"relative-pronouns": {"videoId": "fnD1VaLpsCA", "title": "How to Use Relative Pronouns in Spanish — The Language Tutor Lesson 73"},
"future-vs-ir-a": {"videoId": "2nxynOxr_h4", "title": "Future Tense in Spanish: 3 Ways To Speak About The Future — BaseLang"},
"accent-marks-stress": {"videoId": "mXro8ngx07A", "title": "Spanish Accent Marks: When and How to Use Them — The Language Tutor"},
"se-constructions": {"videoId": "ndxsrGD7b-8", "title": "Understanding 'SE' in Spanish: Reflexive, Passive, and Impersonal Constructions"},
"estar-gerund-progressive": {"videoId": "s6HeVBv-ctM", "title": "How to Use Gerunds '-ing' in Spanish — The Language Tutor Lesson 113"},
"spanish-suffixes": {"videoId": "o88gkstA0ds", "title": "Diminutives and Augmentatives in Spanish — The Language Tutor Lesson 78"},
"common-irregular-verbs": {"videoId": "l0rOmomHSxg", "title": "Irregular Verbs in Spanish — The Language Tutor Lesson 21"},
"types-of-irregular-verbs": {"videoId": "-XogD_S7pY4", "title": "Master IRREGULAR VERBS in the PRESENT TENSE | Complete Spanish Lesson"},
"present-indicative-conjugation": {"videoId": "8HWXJjxvOTE", "title": "Spanish Present Tense: Regular -AR -ER -IR verb conjugation"},
"articles-and-gender": {"videoId": "YeTIwDcKwZ4", "title": "Definite and Indefinite Articles in Spanish — The Language Tutor Lesson 11"},
"possessive-adjectives": {"videoId": "Y4Aw1jy0ohE", "title": "Possessive Adjectives in Spanish — BaseLang"},
"demonstrative-adjectives": {"videoId": "g4UzE8c2wik", "title": "How to Use This, These, That and Those in Spanish — The Language Tutor Lesson 56"},
"greetings-farewells": {"videoId": "AqfQQZVmTUw", "title": "Every Spanish Greeting You Need (Formal, Casual & Slang) — The Language Tutor"},
"poder-infinitive": {"videoId": "hCUbz5942EY", "title": "Spanish - The Verb 'Poder' Explained In 3 Minutes"},
"al-del-contractions": {"videoId": "nWPZZWIwWxg", "title": "Spanish Contractions AL and DEL — The Language Tutor Lesson 15"},
"prepositional-pronouns": {"videoId": "quzRXk0oKp8", "title": "Mastering Prepositional Pronouns in Spanish — The Language Tutor Lesson 71"},
"irregular-yo-verbs": {"videoId": "bM9NLgaeUvw", "title": "What are YO GO verbs in Spanish? — BaseLang"},
"stem-changing-verbs": {"videoId": "WB2ThQauaWo", "title": "Stem Changing Verbs in Spanish: Explained For Beginners — BaseLang"},
"stressed-possessives": {"videoId": "OL86D_omkSQ", "title": "Learning Possessive Pronouns in Spanish — The Language Tutor Lesson 68"},
"present-perfect-tense": {"videoId": "-uHwV5Lu310", "title": "Present Perfect Indicative Tense in Spanish — The Language Tutor Lesson 55"},
"future-perfect-tense": {"videoId": "6cgc5ENNbR4", "title": "Understanding the Future Perfect Indicative in Spanish — The Language Tutor Lesson 62"}
}
}
+79
View File
@@ -0,0 +1,79 @@
# Curated YouTube Videos
Every tense guide and grammar note in the app can be tied to a single curated YouTube video. This file is generated from `Conjuga/youtube_videos.json` by `Scripts/generate_videos_markdown.py` — regenerate when you add or change entries.
- Total tense-guide entries: **20** of 20
- Total grammar-note entries: **36** of 36
- Last verified: **2026-04-23** (run `python3 Scripts/generate_videos_markdown.py` to refresh)
Like counts are often blank because YouTube hides the public count on most videos for signed-out requests. Titles and durations are pulled live from YouTube; unavailable entries mean the video has been taken down, made private, or region-locked. A few entries marked "not available on this app" are a transient yt-dlp extraction limit — the video itself still plays fine when tapping Stream in the app.
## Tense guides
Tied to `TenseGuide.tenseId` in the Guide tab.
| Tense ID | Title | Channel | Uploaded | Duration | Views | Likes | URL |
|---|---|---|---|---|---|---|---|
| `ind_presente` | Spanish Present Tense: Regular -AR -ER -IR verb conjugation | Learn Spanish with Spanish55 | 2019-10-18 | 7:14 | 51,288 | 233 | [watch](https://www.youtube.com/watch?v=8HWXJjxvOTE) |
| `ind_preterito` | Past Tense Verbs You Must Learn in Spanish \| The Language Tutor *Lesson 64* | The Language Tutor - Spanish | 2020-04-05 | 9:03 | 50,837 | 1,433 | [watch](https://www.youtube.com/watch?v=0PR7G81gKEc) |
| `ind_imperfecto` | Mastering the Imperfect Tense in Spanish \| The Language Tutor *Lesson 50* | The Language Tutor - Spanish | 2019-12-22 | 19:43 | 385,678 | 8,420 | [watch](https://www.youtube.com/watch?v=AmnTX30VliE) |
| `ind_futuro` | Spanish Verbs in Future Tense \| The Language Tutor *Lesson 51* | The Language Tutor - Spanish | 2019-12-30 | 13:58 | 389,408 | 9,542 | [watch](https://www.youtube.com/watch?v=U42loE1zhdw) |
| `ind_perfecto` | Present Perfect Indicative Tense in Spanish \| The Language Tutor *Lesson 55* | The Language Tutor - Spanish | 2020-01-29 | 18:09 | 273,176 | 6,676 | [watch](https://www.youtube.com/watch?v=-uHwV5Lu310) |
| `ind_pluscuamperfecto` | Understanding Past Perfect Indicative Tense in Spanish \| The Language Tutor *Lesson 57* | The Language Tutor - Spanish | 2020-02-09 | 7:46 | 132,330 | 3,400 | [watch](https://www.youtube.com/watch?v=T_M3h88BUUw) |
| `ind_futuro_perfecto` | Understanding the Future Perfect Indicative in Spanish \| The Language Tutor *Lesson 62* | The Language Tutor - Spanish | 2020-03-22 | 7:59 | 58,757 | 1,547 | [watch](https://www.youtube.com/watch?v=6cgc5ENNbR4) |
| `ind_preterito_anterior` | The Past Anterior Tense in Spanish \| The Language Tutor *Lesson 65* | The Language Tutor - Spanish | 2020-04-12 | 8:37 | 45,389 | 1,124 | [watch](https://www.youtube.com/watch?v=OCCgeYLlqck) |
| `cond_presente` | Understanding the Conditional Tense in Spanish \| The Language Tutor *Lesson 52* | The Language Tutor - Spanish | 2020-01-05 | 10:01 | 222,400 | 5,338 | [watch](https://www.youtube.com/watch?v=nRaMf1Y1TCM) |
| `cond_perfecto` | Mastering the Conditional Perfect Tense in Spanish \| The Language Tutor *Lesson 63* | The Language Tutor - Spanish | 2020-03-29 | 6:07 | 44,304 | 1,300 | [watch](https://www.youtube.com/watch?v=nlF-8kg-xaM) |
| `subj_presente` | The Subjunctive in Spanish \| The Language Tutor *Lesson 58* | The Language Tutor - Spanish | 2020-02-23 | 16:30 | 424,782 | 11,272 | [watch](https://www.youtube.com/watch?v=CRvXpo45oHw) |
| `subj_imperfecto_1` | Imperfect Subjunctive in Spanish \| The Language Tutor *Lesson 59* | The Language Tutor - Spanish | 2020-03-01 | 18:04 | 194,900 | 4,226 | [watch](https://www.youtube.com/watch?v=5VkCYZGvNlI) |
| `subj_imperfecto_2` | Imperfect Subjunctive in Spanish \| The Language Tutor *Lesson 59* | The Language Tutor - Spanish | 2020-03-01 | 18:04 | 194,900 | 4,226 | [watch](https://www.youtube.com/watch?v=5VkCYZGvNlI) |
| `subj_perfecto` | Unlock Fluent Spanish: Present Perfect Subjunctive Mastery! | The Language Tutor - Spanish | 2020-03-08 | 6:47 | 89,060 | 2,105 | [watch](https://www.youtube.com/watch?v=Sm2DDq99Uzw) |
| `subj_pluscuamperfecto_1` | Understanding the Past Perfect Subjunctive in Spanish \| The Language Tutor *Lesson 61* | The Language Tutor - Spanish | 2020-03-15 | 8:51 | 75,462 | 1,862 | [watch](https://www.youtube.com/watch?v=am0YiYkTQ_E) |
| `subj_pluscuamperfecto_2` | Understanding the Past Perfect Subjunctive in Spanish \| The Language Tutor *Lesson 61* | The Language Tutor - Spanish | 2020-03-15 | 8:51 | 75,462 | 1,862 | [watch](https://www.youtube.com/watch?v=am0YiYkTQ_E) |
| `subj_futuro` | Spanish Answers, Episode 10: Future Subjunctive | Spanish Answers | 2019-05-14 | 12:52 | 2,580 | 60 | [watch](https://www.youtube.com/watch?v=YPWJsmD3hN4) |
| `subj_futuro_perfecto` | Free Spanish Lessons 151-Spanish Subjunctive tense:Future Perfect-Video 1/2 | Aprender Idiomas y Cultura General con Rodrigo | 2014-05-18 | 10:00 | 2,025 | 20 | [watch](https://www.youtube.com/watch?v=9vmo2C-0iuQ) |
| `imp_afirmativo` | How to Make Commands in Spanish \| The Language Tutor *Lesson 54* | The Language Tutor - Spanish | 2020-01-19 | 16:50 | 307,013 | 7,142 | [watch](https://www.youtube.com/watch?v=C2UnO5khpi4) |
| `imp_negativo` | How to Make Commands in Spanish \| The Language Tutor *Lesson 54* | The Language Tutor - Spanish | 2020-01-19 | 16:50 | 307,013 | 7,142 | [watch](https://www.youtube.com/watch?v=C2UnO5khpi4) |
## Grammar notes
Tied to `GrammarNote.id` (hand-authored + generated) in the Guide → Grammar tab.
| Grammar ID | Title | Channel | Uploaded | Duration | Views | Likes | URL |
|---|---|---|---|---|---|---|---|
| `ser-vs-estar` | Ser vs Estar: Spanish Lesson #3 | BaseLang | 2016-04-15 | 4:38 | 129,144 | 785 | [watch](https://www.youtube.com/watch?v=tFXGCUi2yls) |
| `por-vs-para` | When to Use Por or Para in Spanish \| The Language Tutor *Lesson 47* | The Language Tutor - Spanish | 2019-12-01 | 14:01 | 261,212 | 7,552 | [watch](https://www.youtube.com/watch?v=02Am4LlPGjU) |
| `preterite-vs-imperfect` | Spanish Past Tense: When to Use the Preterite vs Imperfect | BaseLang | 2025-01-14 | 4:20 | 37,948 | — | [watch](https://www.youtube.com/watch?v=LvhO2-azzig) |
| `subjunctive-triggers` | Spanish Subjunctive Simplified For Beginners | BaseLang | 2022-01-14 | 9:58 | 72,998 | — | [watch](https://www.youtube.com/watch?v=pMAMDgNNPB0) |
| `reflexive-verbs` | Reflexive Verbs in Spanish \| The Language Tutor *Lesson 37* | The Language Tutor - Spanish | 2019-09-22 | 10:37 | 324,262 | 7,345 | [watch](https://www.youtube.com/watch?v=_uH_tosBLyo) |
| `object-pronouns` | Intro To Spanish Direct And Indirect Object Pronouns | BaseLang | 2022-01-12 | 9:44 | 169,076 | — | [watch](https://www.youtube.com/watch?v=gEe4NW1FXx4) |
| `gustar-like-verbs` | How to Use Gustar in Spanish \| The Language Tutor *Lesson 121* | The Language Tutor - Spanish | 2021-12-12 | 14:06 | 173,299 | — | [watch](https://www.youtube.com/watch?v=SAfXpyZlz-I) |
| `comparatives-superlatives` | Comparatives and Superlatives in Spanish \| The Language Tutor *Lesson 123* | The Language Tutor - Spanish | 2022-05-10 | 15:57 | 90,246 | 2,085 | [watch](https://www.youtube.com/watch?v=U74ClJsbfb0) |
| `conditional-if-clauses` | Si Clauses: The Spanish Hypothetical Explained | BaseLang | 2022-05-13 | 8:59 | 16,328 | — | [watch](https://www.youtube.com/watch?v=thvW8qVsqkE) |
| `commands-imperative` | How to Make Commands in Spanish \| The Language Tutor *Lesson 54* | The Language Tutor - Spanish | 2020-01-19 | 16:50 | 307,013 | 7,142 | [watch](https://www.youtube.com/watch?v=C2UnO5khpi4) |
| `saber-vs-conocer` | _(unavailable — Private video)_ | — | — | — | — | — | [watch](https://www.youtube.com/watch?v=j87i7MVCvIE) |
| `double-negatives` | Understanding Negation Words in Spanish \| The Language Tutor *Lesson 67* | The Language Tutor - Spanish | 2020-04-26 | 21:36 | 150,024 | 3,888 | [watch](https://www.youtube.com/watch?v=Y887wmI0O_o) |
| `adjective-placement` | Adjectives and Nouns Working Together in Spanish \| The Language Tutor *Lesson 66* | The Language Tutor - Spanish | 2020-04-19 | 17:17 | 70,340 | 1,980 | [watch](https://www.youtube.com/watch?v=2B_TK_aun8E) |
| `tener-expressions` | The Verb Tener: Spanish Lesson #17 | BaseLang | 2016-05-03 | 4:16 | 7,948 | 76 | [watch](https://www.youtube.com/watch?v=189Qg68VCmo) |
| `personal-a` | Learn Spanish-Preposition "A" vs. Personal “A” | castellano4U | 2017-04-04 | 11:45 | 1,976 | — | [watch](https://www.youtube.com/watch?v=B38CwLxgmOc) |
| `relative-pronouns` | How to Use Relative Pronouns in Spanish \| The Language Tutor *Lesson 73* | The Language Tutor - Spanish | 2020-06-07 | 11:02 | 57,363 | — | [watch](https://www.youtube.com/watch?v=fnD1VaLpsCA) |
| `future-vs-ir-a` | Future Tense in Spanish: 3 Ways To Speak About The Future | BaseLang | 2022-01-05 | 9:57 | 44,300 | — | [watch](https://www.youtube.com/watch?v=2nxynOxr_h4) |
| `accent-marks-stress` | Spanish Accent Marks: When and How to Use Them | The Language Tutor - Spanish | 2020-06-14 | 14:09 | 98,134 | 3,242 | [watch](https://www.youtube.com/watch?v=mXro8ngx07A) |
| `se-constructions` | Understanding "SE" in Spanish: Reflexive, Passive, and Impersonal Constructions EXPLAINED | Learn Spanish with Spanish55 | 2024-08-16 | 3:57 | 1,271 | 61 | [watch](https://www.youtube.com/watch?v=ndxsrGD7b-8) |
| `estar-gerund-progressive` | How to Use Gerunds "-ing" in Spanish \| The Language Tutor *Lesson 113* | The Language Tutor - Spanish | 2021-06-20 | 10:29 | 54,552 | 2,091 | [watch](https://www.youtube.com/watch?v=s6HeVBv-ctM) |
| `spanish-suffixes` | Diminutives and Augmentatives in Spanish \| The Language Tutor *Lesson 78* | The Language Tutor - Spanish | 2020-07-12 | 17:03 | 42,033 | — | [watch](https://www.youtube.com/watch?v=o88gkstA0ds) |
| `common-irregular-verbs` | Irregular Verbs in Spanish \| The Language Tutor *Lesson 21* | The Language Tutor - Spanish | 2019-06-09 | 12:21 | 301,865 | 6,338 | [watch](https://www.youtube.com/watch?v=l0rOmomHSxg) |
| `types-of-irregular-verbs` | _(unavailable — The following content is not available on this app)_ | — | — | — | — | — | [watch](https://www.youtube.com/watch?v=-XogD_S7pY4) |
| `present-indicative-conjugation` | Spanish Present Tense: Regular -AR -ER -IR verb conjugation | Learn Spanish with Spanish55 | 2019-10-18 | 7:14 | 51,288 | 233 | [watch](https://www.youtube.com/watch?v=8HWXJjxvOTE) |
| `articles-and-gender` | Definite and Indefinite Articles in Spanish \| The Language Tutor *Lesson 11* | The Language Tutor - Spanish | 2019-03-31 | 5:37 | 355,893 | 7,396 | [watch](https://www.youtube.com/watch?v=YeTIwDcKwZ4) |
| `possessive-adjectives` | Possessive Adjectives in Spanish | BaseLang | 2022-06-27 | 13:40 | 15,925 | — | [watch](https://www.youtube.com/watch?v=Y4Aw1jy0ohE) |
| `demonstrative-adjectives` | How to Use This, These, That and Those in Spanish \| The Language Tutor *Lesson 56* | The Language Tutor - Spanish | 2020-02-02 | 13:34 | 185,671 | 6,689 | [watch](https://www.youtube.com/watch?v=g4UzE8c2wik) |
| `greetings-farewells` | Every Spanish Greeting You Need (Formal, Casual & Slang) | The Language Tutor - Spanish | 2019-03-10 | 9:54 | 673,751 | 17,161 | [watch](https://www.youtube.com/watch?v=AqfQQZVmTUw) |
| `poder-infinitive` | Spanish - The Verb “Poder“ Explained In 3 Minutes | TheLanguageBro | 2023-07-01 | 3:16 | 4,913 | 106 | [watch](https://www.youtube.com/watch?v=hCUbz5942EY) |
| `al-del-contractions` | Spanish Contractions AL and DEL \| The Language Tutor * Lesson 15 * | The Language Tutor - Spanish | 2019-04-28 | 3:34 | 211,085 | 5,457 | [watch](https://www.youtube.com/watch?v=nWPZZWIwWxg) |
| `prepositional-pronouns` | Mastering Prepositional Pronouns in Spanish \| The Language Tutor *Lesson 71* | The Language Tutor - Spanish | 2020-05-24 | 13:34 | 93,929 | — | [watch](https://www.youtube.com/watch?v=quzRXk0oKp8) |
| `irregular-yo-verbs` | What are YO GO verbs in Spanish? | BaseLang | 2022-04-21 | 5:08 | 17,894 | — | [watch](https://www.youtube.com/watch?v=bM9NLgaeUvw) |
| `stem-changing-verbs` | Stem Changing Verbs in Spanish: Explained For Beginners | BaseLang | 2022-05-17 | 7:31 | 59,959 | — | [watch](https://www.youtube.com/watch?v=WB2ThQauaWo) |
| `stressed-possessives` | Learning Possessive Pronouns in Spanish \| The Language Tutor *Lesson 68* | The Language Tutor - Spanish | 2020-05-03 | 10:01 | 129,223 | 3,671 | [watch](https://www.youtube.com/watch?v=OL86D_omkSQ) |
| `present-perfect-tense` | Present Perfect Indicative Tense in Spanish \| The Language Tutor *Lesson 55* | The Language Tutor - Spanish | 2020-01-29 | 18:09 | 273,176 | 6,676 | [watch](https://www.youtube.com/watch?v=-uHwV5Lu310) |
| `future-perfect-tense` | Understanding the Future Perfect Indicative in Spanish \| The Language Tutor *Lesson 62* | The Language Tutor - Spanish | 2020-03-22 | 7:59 | 58,757 | 1,547 | [watch](https://www.youtube.com/watch?v=6cgc5ENNbR4) |
@@ -0,0 +1,95 @@
import XCTest
/// Screenshot every chapter of the textbook one top + one bottom frame each
/// so you can visually audit parsing / rendering issues across all 30 chapters.
final class AllChaptersScreenshotTests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = true
}
func testScreenshotEveryChapter() throws {
let app = XCUIApplication()
app.launchArguments += ["-onboardingComplete", "YES"]
app.launch()
let courseTab = app.tabBars.buttons["Course"]
XCTAssertTrue(courseTab.waitForExistence(timeout: 5))
courseTab.tap()
let textbookRow = app.buttons.containing(NSPredicate(
format: "label CONTAINS[c] 'Complete Spanish'"
)).firstMatch
XCTAssertTrue(textbookRow.waitForExistence(timeout: 5))
textbookRow.tap()
// NOTE: SwiftUI List preserves scroll position across navigation pushes,
// so visiting chapters in-order means the next one is already visible
// after we return from the previous one. No need to reset.
attach(app, name: "00-chapter-list-top")
for chapter in 1...30 {
guard let row = findChapterRow(app: app, chapter: chapter) else {
XCTFail("Chapter \(chapter) row not reachable")
continue
}
row.tap()
// Chapter body wait until the chapter's title appears as a nav bar label
_ = app.navigationBars.firstMatch.waitForExistence(timeout: 3)
attach(app, name: String(format: "ch%02d-top", chapter))
// One big scroll to sample the bottom of the chapter
dragFullScreen(app, direction: .up)
dragFullScreen(app, direction: .up)
attach(app, name: String(format: "ch%02d-bottom", chapter))
tapNavBack(app)
// Small settle wait
_ = app.navigationBars.firstMatch.waitForExistence(timeout: 2)
}
}
// MARK: - Helpers
private enum DragDirection { case up, down }
private func dragFullScreen(_ app: XCUIApplication, direction: DragDirection) {
let top = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.12))
let bot = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.88))
switch direction {
case .up: bot.press(forDuration: 0.1, thenDragTo: top)
case .down: top.press(forDuration: 0.1, thenDragTo: bot)
}
}
private func findChapterRow(app: XCUIApplication, chapter: Int) -> XCUIElement? {
// Chapter row accessibility label: "<n>, <title>, ..." (SwiftUI composes
// label from inner Texts). Match by starting number.
let predicate = NSPredicate(format: "label BEGINSWITH %@", "\(chapter),")
let row = app.buttons.matching(predicate).firstMatch
if row.exists && row.isHittable { return row }
// Scroll down up to 8 times searching for the row chapters visited
// in order, so usually 02 swipes suffice.
for _ in 0..<8 {
if row.exists && row.isHittable { return row }
dragFullScreen(app, direction: .up)
}
return row.exists ? row : nil
}
private func tapNavBack(_ app: XCUIApplication) {
let back = app.navigationBars.buttons.firstMatch
if back.exists && back.isHittable { back.tap() }
}
private func attach(_ app: XCUIApplication, name: String) {
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
}
@@ -0,0 +1,66 @@
import XCTest
final class StemChangeToggleTests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
func testStemChangeConjugationToggle() throws {
let app = XCUIApplication()
app.launchArguments += ["-onboardingComplete", "YES"]
app.launch()
// Course LanGo Beginner I Week 4 E-IE stem-changing verbs
app.tabBars.buttons["Course"].tap()
// Locate the E-IE deck row. Deck titles appear as static text / button.
// Scroll until visible, then tap.
let deckPredicate = NSPredicate(format: "label CONTAINS[c] 'E-IE stem changing verbs' AND NOT label CONTAINS[c] 'REVÉS'")
let deckRow = app.buttons.matching(deckPredicate).firstMatch
let listRef = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85))
let topRef = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.10))
for _ in 0..<12 {
if deckRow.exists && deckRow.isHittable { break }
listRef.press(forDuration: 0.1, thenDragTo: topRef)
}
XCTAssertTrue(deckRow.waitForExistence(timeout: 3), "E-IE deck row missing")
deckRow.tap()
attach(app, name: "01-deck-top")
// Tap "Show conjugation" on the first card
let showBtn = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Show conjugation'")).firstMatch
XCTAssertTrue(showBtn.waitForExistence(timeout: 3), "Show conjugation button missing")
showBtn.tap()
// Wait for the conjugation rows + animation to settle.
let yoLabel = app.staticTexts["yo"].firstMatch
XCTAssertTrue(yoLabel.waitForExistence(timeout: 3), "yo row not rendered")
// Give the transition time to complete before snapshotting.
Thread.sleep(forTimeInterval: 0.6)
attach(app, name: "02-conjugation-open")
// Also confirm all expected person labels are rendered.
for person in ["yo", "", "nosotros"] {
XCTAssertTrue(
app.staticTexts[person].firstMatch.exists,
"missing conjugation row for \(person)"
)
}
// Tap again to hide
let hideBtn = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Hide conjugation'")).firstMatch
XCTAssertTrue(hideBtn.waitForExistence(timeout: 2))
hideBtn.tap()
}
private func attach(_ app: XCUIApplication, name: String) {
let s = app.screenshot()
let a = XCTAttachment(screenshot: s)
a.name = name
a.lifetime = .keepAlways
add(a)
}
}
@@ -0,0 +1,80 @@
import XCTest
final class TextbookFlowUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
func testTextbookFlow() throws {
let app = XCUIApplication()
// Skip onboarding via defaults (already set by run script, but harmless to override)
app.launchArguments += ["-onboardingComplete", "YES"]
app.launch()
// Dashboard should be default tab. Switch to Course.
let courseTab = app.tabBars.buttons["Course"]
XCTAssertTrue(courseTab.waitForExistence(timeout: 5), "Course tab missing")
courseTab.tap()
// Attach a screenshot of the Course list
attach(app, name: "01-course-list")
// Tap the Textbook entry
let textbookRow = app.buttons.containing(NSPredicate(
format: "label CONTAINS[c] 'Complete Spanish'"
)).firstMatch
XCTAssertTrue(textbookRow.waitForExistence(timeout: 5), "Textbook row missing in Course")
textbookRow.tap()
attach(app, name: "02-textbook-chapter-list")
// Tap chapter 1 should navigate to reader
let chapterOneRow = app.buttons.containing(NSPredicate(
format: "label CONTAINS[c] 'Nouns, Articles'"
)).firstMatch
XCTAssertTrue(chapterOneRow.waitForExistence(timeout: 5), "Chapter 1 row missing")
chapterOneRow.tap()
attach(app, name: "03-chapter-body")
// Find the first exercise link ("Exercise 1.1")
let exerciseRow = app.buttons.containing(NSPredicate(
format: "label CONTAINS[c] 'Exercise 1.1'"
)).firstMatch
XCTAssertTrue(exerciseRow.waitForExistence(timeout: 5), "Exercise 1.1 link missing")
exerciseRow.tap()
attach(app, name: "04-exercise-view")
// Check presence of input fields: at least a few numbered prompts
// Text fields use SwiftUI placeholder "Your answer"
let firstField = app.textFields["Your answer"].firstMatch
XCTAssertTrue(firstField.waitForExistence(timeout: 5), "No input fields rendered for exercise")
firstField.tap()
firstField.typeText("el")
attach(app, name: "05-exercise-typed-el")
// Tap Check answers
let checkButton = app.buttons["Check answers"]
XCTAssertTrue(checkButton.waitForExistence(timeout: 3), "Check answers button missing")
checkButton.tap()
attach(app, name: "06-exercise-graded")
// The first answer to Exercise 1.1 is "el" we should see the first prompt
// graded correct. Iterating too deeply is fragile; just take a screenshot
// and check for presence of either a checkmark-like label or "Try again".
let tryAgain = app.buttons["Try again"]
XCTAssertTrue(tryAgain.waitForExistence(timeout: 3), "Grading did not complete")
}
private func attach(_ app: XCUIApplication, name: String) {
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
}
@@ -0,0 +1,53 @@
import XCTest
final class VocabGridTests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
/// Verifies the chapter reader renders vocab tables as a paired SpanishEnglish grid.
func testChapter4VocabGrid() throws {
let app = XCUIApplication()
app.launchArguments += ["-onboardingComplete", "YES"]
app.launch()
app.tabBars.buttons["Course"].tap()
let textbookRow = app.buttons.containing(NSPredicate(
format: "label CONTAINS[c] 'Complete Spanish'"
)).firstMatch
XCTAssertTrue(textbookRow.waitForExistence(timeout: 5))
textbookRow.tap()
let ch4 = app.buttons["textbook-chapter-row-4"]
XCTAssertTrue(ch4.waitForExistence(timeout: 3))
ch4.tap()
attach(app, name: "01-ch4-top")
// Tap the first vocab disclosure "Vocabulary (N items)"
let vocabButton = app.buttons.matching(NSPredicate(
format: "label BEGINSWITH 'Vocabulary ('"
)).firstMatch
XCTAssertTrue(vocabButton.waitForExistence(timeout: 3))
vocabButton.tap()
Thread.sleep(forTimeInterval: 0.4)
attach(app, name: "02-ch4-vocab-open")
// Scroll a little and screenshot a deeper vocab numbers table is
// typically a few screens down in chapter 4.
app.swipeUp(velocity: .fast)
app.swipeUp(velocity: .fast)
attach(app, name: "03-ch4-deeper")
}
private func attach(_ app: XCUIApplication, name: String) {
let s = app.screenshot()
let a = XCTAttachment(screenshot: s)
a.name = name
a.lifetime = .keepAlways
add(a)
}
}
+7 -9
View File
@@ -41,21 +41,19 @@ struct CombinedProvider: TimelineProvider {
private func fetchWordOfDay(for date: Date) -> WordOfDay? { private func fetchWordOfDay(for date: Date) -> WordOfDay? {
guard let localURL = SharedStore.localStoreURL() else { return nil } guard let localURL = SharedStore.localStoreURL() else { return nil }
// MUST declare all 6 local entities to match the main app's schema. // Open the store with the SAME schema as the main app. A subset schema
// Declaring a subset would cause SwiftData to destructively migrate the store // would make SwiftData destructively migrate the store on open and drop
// on open, dropping the entities not listed here. // every unlisted table (this is how widget refreshes kept wiping the
// bundled Book rows, and TextbookChapter before them).
let schema = Schema(SharedStore.localSchemaModels)
let config = ModelConfiguration( let config = ModelConfiguration(
"local", "local",
schema: Schema([ schema: schema,
Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
]),
url: localURL, url: localURL,
cloudKitDatabase: .none cloudKitDatabase: .none
) )
guard let container = try? ModelContainer( guard let container = try? ModelContainer(
for: Verb.self, VerbForm.self, IrregularSpan.self, for: schema,
TenseGuide.self, CourseDeck.self, VocabCard.self,
configurations: config configurations: config
) else { return nil } ) else { return nil }
+7 -9
View File
@@ -32,21 +32,19 @@ struct WordOfDayProvider: TimelineProvider {
private func fetchWordOfDay(for date: Date) -> WordOfDay? { private func fetchWordOfDay(for date: Date) -> WordOfDay? {
guard let localURL = SharedStore.localStoreURL() else { return nil } guard let localURL = SharedStore.localStoreURL() else { return nil }
// MUST declare all 6 local entities to match the main app's schema. // Open the store with the SAME schema as the main app. A subset schema
// Declaring a subset would cause SwiftData to destructively migrate the store // would make SwiftData destructively migrate the store on open and drop
// on open, dropping the entities not listed here. // every unlisted table (this is how widget refreshes kept wiping the
// bundled Book rows, and TextbookChapter before them).
let schema = Schema(SharedStore.localSchemaModels)
let config = ModelConfiguration( let config = ModelConfiguration(
"local", "local",
schema: Schema([ schema: schema,
Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
]),
url: localURL, url: localURL,
cloudKitDatabase: .none cloudKitDatabase: .none
) )
guard let container = try? ModelContainer( guard let container = try? ModelContainer(
for: Verb.self, VerbForm.self, IrregularSpan.self, for: schema,
TenseGuide.self, CourseDeck.self, VocabCard.self,
configurations: config configurations: config
) else { return nil } ) else { return nil }
+1
View File
@@ -0,0 +1 @@
build/
+102
View File
@@ -0,0 +1,102 @@
# Books pipeline
Turns any EPUB into a chapter-structured JSON file the app bundles and reads.
## TL;DR
```bash
cd Conjuga/Scripts/books
./run.sh /path/to/book.epub --slug my-book-slug
```
This runs Phase 1 (extract) and Phase 2 (manifest jobs), then stops and tells you how many translation jobs are pending. Run those via Claude Code subagents (Phase 2.5 below), then re-run `./run.sh` to bundle the final file.
## Phases
| Phase | Script | What it does | Output |
|---|---|---|---|
| 1 | `extract_epub.py` | Unzip the EPUB, walk `content.opf` spine + `toc.ncx` navMap, group HTML files into chapters, strip HTML→text. | `build/<slug>/chapters.json` |
| 2 | `translate_chapters.py` | Split each chapter into ~30-paragraph translation batches. Each batch becomes a job with its own input/output file. **Resumable**: jobs whose output file already exists are skipped. | `build/<slug>/jobs/<jobid>.input.json` + `_pending.txt` |
| 2b | `build_glossary.py` | Tokenize every Spanish paragraph the same way the app does, collect the distinct words with example sentences, split into ~150-word glossary batches. **Resumable** the same way. | `build/<slug>/glossary/<jobid>.input.json` + `_pending.txt` |
| 2.5 | Claude Code subagents | Drain **both** manifests: translate the chapter jobs *and* the glossary jobs, writing each job's `<jobid>.output.json`. See "Running translations" below. | `build/<slug>/{jobs,glossary}/<jobid>.output.json` |
| 3 | `bundle_book.py` | Merge `chapters.json` + every translation `*.output.json` + every glossary `*.output.json` into the final bundled JSON the app reads. | `Conjuga/Conjuga/book_<slug>.json` |
`run.sh` chains 1 → 2 → 2b → 3. If Phase 2 or 2b produces pending jobs, Phase 3 still runs but bundles with placeholders so you can preview app structure before the LLM passes complete. Re-running `run.sh` after subagents fill in the outputs gives you the real bundled file.
The glossary is the book reader's primary word-lookup source: every distinct word translated once, in context, so taps are instant, cover the whole book, and don't mis-resolve homographs (e.g. "como" as the conjunction vs. the verb *comer*). This phase is a permanent part of the pipeline — every book imported this way gets a glossary.
## Adding a new book
1. **Drop the EPUB** anywhere on disk.
2. **Run Phase 1+2**:
```bash
cd Conjuga/Scripts/books
./run.sh /path/to/book.epub --slug my-book
```
Sanity-check the chapter list it prints. If chapter grouping looks wrong (e.g. an EPUB without a usable `toc.ncx`), `extract_epub.py` will need a fallback heuristic — see "Open assumptions" below.
3. **Run translations** (Phase 2.5). The default approach is to spawn Claude Code subagents from inside a Claude Code session pointed at this repo:
There are **two** manifests to drain — translation and glossary:
- `build/<slug>/jobs/_pending.txt` with prompt `build/<slug>/jobs/_prompt_template.md`
- `build/<slug>/glossary/_pending.txt` with prompt `build/<slug>/glossary/_prompt_template.md`
For each pending job ID, hand a subagent the matching prompt with `<JOB_INPUT_PATH>` / `<JOB_OUTPUT_PATH>` filled in. The subagent reads the input, produces the translation/glossary, and writes the output. Resumable — interrupted runs just leave the missing job IDs in `_pending.txt`.
Cluster jobs into agent batches of ~510 jobs each to keep per-agent context manageable. ~5 parallel agents is a good throughput target.
4. **Bundle**:
```bash
./run.sh /path/to/book.epub --slug my-book # re-running pulls in the new outputs
# or directly:
python3 bundle_book.py my-book --require-all
```
`--require-all` will fail loudly if any job is still missing.
5. **Bump `bookDataVersion`** in `DataLoader.swift` so the in-app store re-seeds the new book on next launch (or any time you re-run with new translations).
6. **Verify the file is bundled** in `Conjuga.xcodeproj`. The script writes `book_<slug>.json` into `Conjuga/Conjuga/Resources/`; if that folder is part of a recursive group reference, Xcode picks it up automatically. Otherwise, add it manually or via the `xcodeproj` ruby gem.
## File layout
```
Conjuga/Scripts/books/
├── extract_epub.py # Phase 1
├── translate_chapters.py # Phase 2
├── build_glossary.py # Phase 2b
├── bundle_book.py # Phase 3
├── run.sh # Orchestrator
└── build/ # gitignored
└── <slug>/
├── chapters.json
├── jobs/ # translation jobs
│ ├── _pending.txt
│ ├── _prompt_template.md
│ ├── ch01_b00.input.json
│ ├── ch01_b00.output.json
│ └── ...
└── glossary/ # glossary jobs (Phase 2b)
├── _pending.txt
├── _prompt_template.md
├── gloss_b00.input.json
├── gloss_b00.output.json
└── ...
```
The final output (`book_<slug>.json`) lives at `Conjuga/Conjuga/book_<slug>.json` so the iOS app bundle includes it. (Existing `textbook_data.json` / `conjuga_data.json` use the same layout — files in the app target root rather than a Resources subgroup.)
## Open assumptions
- **TOC drives chapter boundaries.** If an EPUB ships without a usable `toc.ncx`, or the navMap is too granular (e.g. one navPoint per page), `extract_epub.py` will need a fallback that groups by `<h1>` headings in spine order.
- **Spanish bold tags = inline emphasis.** The Olly Richards books bold vocab hints inside paragraphs. We strip the bold and let the in-app dictionary lookup handle definitions instead. If a future book uses bold for something else (titles, etc.), revisit.
- **Translation is per-paragraph 1:1.** Subagents must preserve paragraph count and order. `bundle_book.py` will warn + pad/truncate if a job's output array length doesn't match its input — but that's a sign the subagent misbehaved.
## Out of scope (intentional)
- OCR of vocab image tables (use `Scripts/textbook/` if your book is image-heavy).
- Exercise extraction (textbook pipeline).
- Per-occurrence word sense disambiguation. The glossary has one entry per
distinct word, translated in context; a word genuinely used in two senses in
the same book gets its dominant sense. The runtime `DictionaryService` + the
on-device LLM remain as fallbacks for anything the glossary misses.
- Cover image extraction (covers are derived from a color hash in the app for now).
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""Phase 2b — build a per-book glossary job manifest.
Scans chapters.json, tokenizes every Spanish paragraph the SAME way the iOS app
does (whitespace split, lowercase, strip leading/trailing punctuation), collects
the distinct words with a few example sentences each, and writes batched
glossary jobs that Claude Code subagents can translate in parallel. Resumable:
jobs whose output file already exists are skipped.
Usage:
python3 build_glossary.py <slug> [--batch-size N] [--max-examples N]
[--build BUILD_DIR]
Inputs:
BUILD_DIR/<slug>/chapters.json (from extract_epub.py)
Outputs:
BUILD_DIR/<slug>/glossary/<jobid>.input.json (one per batch — read by subagents)
BUILD_DIR/<slug>/glossary/_pending.txt (job IDs still missing output)
BUILD_DIR/<slug>/glossary/_prompt_template.md (prompt for each subagent)
Job input shape (.input.json):
{"jobId": "gloss_b00",
"words": [{"word": "taza", "examples": ["...", "..."]}, ...]}
Subagents must write <jobid>.output.json with shape:
{"jobId": "gloss_b00",
"entries": [{"word": "taza", "baseForm": "taza",
"english": "cup", "partOfSpeech": "noun"}, ...]}
`entries` must contain exactly one object per input word.
"""
from __future__ import annotations
import argparse
import json
import re
import unicodedata
from pathlib import Path
PROMPT_TEMPLATE = """\
You are building a Spanish->English glossary for a language-learning app.
Input file: {input_path}
Output file: {output_path}
Read the input file. It contains a JSON object with a `words` array; each item
has a `word` (a lowercase Spanish word exactly as it appears in a book) and
`examples` (sentences from the book that use that word).
For EACH word, produce one entry:
- baseForm: the dictionary base form -- infinitive for verbs, masculine
singular for nouns/adjectives, the word itself for invariant words.
- english: a concise English translation (1-4 words). Use the sense the word
carries in the example sentences. Many Spanish words are both a verb form
AND a function word -- e.g. "como" is "I eat" (verb) and "as/like"
(conjunction). Choose the meaning shown in the examples, not the most common
dictionary sense.
- partOfSpeech: one of verb, noun, adjective, adverb, pronoun, preposition,
conjunction, article, interjection, numeral, proper noun, other.
Write the output file as JSON with this exact shape:
{{"jobId": "<the jobId from the input>", "entries": [
{{"word": "...", "baseForm": "...", "english": "...", "partOfSpeech": "..."}}
]}}
`entries` MUST contain exactly one object per input word, cover every word, and
echo each `word` back verbatim. Write nothing else to disk and produce no other
output.
"""
SENTENCE_SPLIT = re.compile(r"(?<=[.!?…])\s+")
def is_punct(ch: str) -> bool:
"""True for any Unicode punctuation — matches Swift's .punctuationCharacters."""
return unicodedata.category(ch).startswith("P")
def clean_word(token: str) -> str:
"""Mirror BookReaderView.cleanWord: lowercase, strip leading/trailing
punctuation, trim whitespace. Accents are preserved (no folding)."""
t = token.lower()
start, end = 0, len(t)
while start < end and is_punct(t[start]):
start += 1
while end > start and is_punct(t[end - 1]):
end -= 1
return t[start:end].strip()
def has_letter(s: str) -> bool:
return any(c.isalpha() for c in s)
def split_sentences(paragraph: str) -> list[str]:
parts = SENTENCE_SPLIT.split(paragraph.strip())
return [p.strip() for p in parts if p.strip()]
def is_english_front_matter(chapter: dict, threshold: float = 0.5) -> bool:
"""True when most of a chapter's paragraphs are untranslated — i.e. it is
English front matter (Preface, reading guide, …) rather than Spanish story
content. Story chapters still have *some* identical lines (verbatim
`word = meaning` vocab entries), so a majority threshold separates them:
front matter runs ~70-100% identical, stories ~25-35%. Only detectable once
paragraphsEN is populated; raw extracted chapters carry none, so nothing is
skipped on a fresh book's first pass."""
es = [p.strip() for p in chapter.get("paragraphsES", [])]
en = [p.strip() for p in chapter.get("paragraphsEN", [])]
if not en or len(en) != len(es) or not es:
return False
identical = sum(1 for a, b in zip(es, en) if a == b)
return identical / len(es) > threshold
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("slug")
parser.add_argument("--batch-size", type=int, default=150)
parser.add_argument("--max-examples", type=int, default=3)
parser.add_argument("--build", type=Path, default=Path("build"))
args = parser.parse_args()
base = args.build / args.slug
chapters = json.loads((base / "chapters.json").read_text(encoding="utf-8"))
gloss_dir = base / "glossary"
gloss_dir.mkdir(parents=True, exist_ok=True)
examples: dict[str, list[str]] = {}
first_seen: dict[str, int] = {}
order = 0
skipped_front_matter = 0
for ch in chapters["chapters"]:
if is_english_front_matter(ch):
skipped_front_matter += 1
continue
for paragraph in ch.get("paragraphsES", []):
for sentence in split_sentences(paragraph):
cleaned = {clean_word(tok) for tok in sentence.split()}
for w in cleaned:
if not w or not has_letter(w):
continue
if w not in first_seen:
first_seen[w] = order
order += 1
examples[w] = []
bucket = examples[w]
if len(bucket) < args.max_examples and sentence not in bucket:
bucket.append(sentence)
words = sorted(examples.keys(), key=lambda w: first_seen[w])
pending: list[str] = []
completed: list[str] = []
total_jobs = 0
for offset in range(0, len(words), args.batch_size):
chunk = words[offset : offset + args.batch_size]
job_id = f"gloss_b{offset // args.batch_size:02d}"
input_path = gloss_dir / f"{job_id}.input.json"
output_path = gloss_dir / f"{job_id}.output.json"
input_path.write_text(
json.dumps(
{
"jobId": job_id,
"words": [{"word": w, "examples": examples[w]} for w in chunk],
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
total_jobs += 1
(completed if output_path.exists() else pending).append(job_id)
(gloss_dir / "_pending.txt").write_text(
"\n".join(pending) + ("\n" if pending else ""), encoding="utf-8"
)
(gloss_dir / "_prompt_template.md").write_text(
PROMPT_TEMPLATE.format(
input_path="<JOB_INPUT_PATH>", output_path="<JOB_OUTPUT_PATH>"
),
encoding="utf-8",
)
print(f"Skipped front matter: {skipped_front_matter} chapter(s)")
print(f"Distinct words: {len(words)}")
print(f"Total glossary jobs: {total_jobs}")
print(f" Completed: {len(completed)}")
print(f" Pending: {len(pending)}")
print(f"Manifest at: {gloss_dir / '_pending.txt'}")
print(f"Prompt template at: {gloss_dir / '_prompt_template.md'}")
if __name__ == "__main__":
main()
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Merge chapters.json + per-job translation outputs into the final bundled
book_<slug>.json that the iOS app reads from its bundle.
Usage:
python3 bundle_book.py <slug> [--build BUILD_DIR] [--dest DEST_DIR] [--require-all]
Inputs:
BUILD_DIR/<slug>/chapters.json
BUILD_DIR/<slug>/jobs/*.output.json (from translation subagents)
BUILD_DIR/<slug>/glossary/*.output.json (from glossary subagents, Phase 2b)
Output:
DEST_DIR/book_<slug>.json
{
"slug": "...",
"title": "...",
"author": "...",
"language": "...",
"chapters": [
{"id": "ch1", "number": 1, "title": "Preface",
"paragraphsES": ["...", ...],
"paragraphsEN": ["...", ...]},
...
],
"glossary": {
"taza": {"baseForm": "taza", "english": "cup", "partOfSpeech": "noun"},
...
}
}
If --require-all is passed, the script fails if any translation OR glossary job
is missing its output. Otherwise it fills missing translations with empty
strings, leaves missing glossary entries out, and warns.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
DEFAULT_DEST = Path("../../Conjuga")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("slug")
parser.add_argument("--build", type=Path, default=Path("build"))
parser.add_argument("--dest", type=Path, default=None)
parser.add_argument("--require-all", action="store_true")
args = parser.parse_args()
base = args.build / args.slug
chapters = json.loads((base / "chapters.json").read_text(encoding="utf-8"))
jobs_dir = base / "jobs"
# Index translation jobs by chapter -> ordered (offset, paragraphsEN).
chapter_translations: dict[int, list[tuple[int, list[str]]]] = {}
missing: list[str] = []
for input_path in sorted(jobs_dir.glob("*.input.json")):
job_id = input_path.stem.removesuffix(".input")
input_data = json.loads(input_path.read_text(encoding="utf-8"))
output_path = jobs_dir / f"{job_id}.output.json"
if not output_path.exists():
missing.append(job_id)
continue
output_data = json.loads(output_path.read_text(encoding="utf-8"))
paragraphs_en = output_data.get("paragraphsEN", [])
expected = len(input_data["paragraphsES"])
if len(paragraphs_en) != expected:
print(
f"WARN: {job_id} length mismatch — got {len(paragraphs_en)}, "
f"expected {expected}. Padding/truncating.",
file=sys.stderr,
)
if len(paragraphs_en) < expected:
paragraphs_en = paragraphs_en + [""] * (expected - len(paragraphs_en))
else:
paragraphs_en = paragraphs_en[:expected]
chapter_translations.setdefault(input_data["chapter"], []).append(
(input_data["rangeStart"], paragraphs_en)
)
if missing:
msg = f"{len(missing)} translation job(s) missing output: {missing[:5]}{'...' if len(missing) > 5 else ''}"
if args.require_all:
print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(1)
print(f"WARN: {msg} — using empty strings for those paragraphs.", file=sys.stderr)
# Glossary (Phase 2b) — merge every glossary job's entries into one map
# keyed by the cleaned word the app looks up.
glossary_dir = base / "glossary"
glossary: dict[str, dict] = {}
glossary_missing: list[str] = []
if glossary_dir.exists():
for input_path in sorted(glossary_dir.glob("*.input.json")):
job_id = input_path.stem.removesuffix(".input")
output_path = glossary_dir / f"{job_id}.output.json"
if not output_path.exists():
glossary_missing.append(job_id)
continue
output_data = json.loads(output_path.read_text(encoding="utf-8"))
for entry in output_data.get("entries", []):
word = (entry.get("word") or "").strip()
if not word:
continue
glossary[word] = {
"baseForm": entry.get("baseForm") or word,
"english": entry.get("english") or "",
"partOfSpeech": entry.get("partOfSpeech") or "",
}
if glossary_missing:
msg = f"{len(glossary_missing)} glossary job(s) missing output: {glossary_missing[:5]}{'...' if len(glossary_missing) > 5 else ''}"
if args.require_all:
print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(1)
print(f"WARN: {msg} — glossary will be incomplete.", file=sys.stderr)
bundled_chapters: list[dict] = []
for ch in chapters["chapters"]:
translations = sorted(chapter_translations.get(ch["number"], []))
paragraphs_en: list[str] = []
for _, en_chunk in translations:
paragraphs_en.extend(en_chunk)
# Pad to match ES length if jobs were missing for parts of this chapter.
if len(paragraphs_en) < len(ch["paragraphsES"]):
paragraphs_en += [""] * (len(ch["paragraphsES"]) - len(paragraphs_en))
elif len(paragraphs_en) > len(ch["paragraphsES"]):
paragraphs_en = paragraphs_en[: len(ch["paragraphsES"])]
bundled_chapters.append(
{
"id": ch["id"],
"number": ch["number"],
"title": ch["title"],
"paragraphsES": ch["paragraphsES"],
"paragraphsEN": paragraphs_en,
}
)
payload = {
"slug": chapters["slug"],
"title": chapters["title"],
"author": chapters["author"],
"language": chapters["language"],
"chapters": bundled_chapters,
"glossary": glossary,
}
dest_dir = (args.dest or DEFAULT_DEST).resolve()
dest_dir.mkdir(parents=True, exist_ok=True)
out_path = dest_dir / f"book_{args.slug}.json"
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"Wrote {out_path}")
print(f" Chapters: {len(bundled_chapters)}")
print(f" Translated jobs: {sum(len(v) for v in chapter_translations.values())} / {sum(len(v) for v in chapter_translations.values()) + len(missing)}")
print(f" Glossary words: {len(glossary)}")
if __name__ == "__main__":
main()
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""Parse an EPUB into chapters.json for the in-app Books feature.
Usage:
python3 extract_epub.py <epub_path> [--slug SLUG] [--out OUT_DIR]
Defaults:
SLUG derived from the EPUB filename (lowercased, dashed)
OUT_DIR ./build/<slug>
Output:
OUT_DIR/chapters.json
{
"title": "...",
"author": "...",
"language": "...",
"slug": "...",
"chapters": [
{"id": "ch1", "number": 1, "title": "Preface",
"paragraphsES": ["...", "..."]},
...
]
}
How chapter grouping works:
1. Read content.opf manifest (id -> href) and spine (ordered idrefs).
2. Read toc.ncx navMap to get the ordered list of chapter (title, first-href).
3. For each chapter, claim every spine file from its first href up to (but
not including) the next chapter's first href.
4. For each file in the chapter's range, parse <p> elements, strip tags,
normalise whitespace + smart quotes, drop empties.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import unicodedata
import warnings
import zipfile
from pathlib import Path
from typing import Iterable
from xml.etree import ElementTree as ET
from bs4 import BeautifulSoup, XMLParsedAsHTMLWarning
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
NS = {
"opf": "http://www.idpf.org/2007/opf",
"dc": "http://purl.org/dc/elements/1.1/",
"ncx": "http://www.daisy.org/z3986/2005/ncx/",
"xhtml": "http://www.w3.org/1999/xhtml",
}
def _slugify(s: str) -> str:
s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode("ascii")
s = re.sub(r"[^a-zA-Z0-9]+", "-", s).strip("-").lower()
return s or "book"
def _normalise(text: str) -> str:
# Collapse runs of whitespace, normalise smart quotes to plain ones.
text = text.replace(" ", " ")
text = re.sub(r"\s+", " ", text).strip()
text = re.sub(r"\s+([.,;:!?…])", r"\1", text)
text = re.sub(r"([¡¿])\s+", r"\1", text)
return text
def _read_zip_text(zf: zipfile.ZipFile, path: str) -> str:
return zf.read(path).decode("utf-8")
def _container_root(zf: zipfile.ZipFile) -> str:
container = ET.fromstring(_read_zip_text(zf, "META-INF/container.xml"))
rootfile = container.find(".//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile")
if rootfile is None:
raise RuntimeError("Missing rootfile entry in META-INF/container.xml")
return rootfile.attrib["full-path"]
def _parse_opf(zf: zipfile.ZipFile, opf_path: str):
text = _read_zip_text(zf, opf_path)
root = ET.fromstring(text)
title = (root.findtext(".//dc:title", default="", namespaces=NS) or "").strip()
author = (root.findtext(".//dc:creator", default="", namespaces=NS) or "").strip()
language = (root.findtext(".//dc:language", default="", namespaces=NS) or "").strip()
manifest: dict[str, str] = {}
for item in root.findall("opf:manifest/opf:item", NS):
manifest[item.attrib["id"]] = item.attrib["href"]
spine: list[str] = []
for itemref in root.findall("opf:spine/opf:itemref", NS):
spine.append(itemref.attrib["idref"])
ncx_id = root.find("opf:spine", NS).attrib.get("toc") if root.find("opf:spine", NS) is not None else None
ncx_href = manifest.get(ncx_id) if ncx_id else None
return {
"title": title,
"author": author,
"language": language,
"manifest": manifest,
"spine": spine,
"ncx_href": ncx_href,
"opf_dir": str(Path(opf_path).parent) if "/" in opf_path else "",
}
def _parse_ncx(zf: zipfile.ZipFile, ncx_path: str) -> list[dict]:
text = _read_zip_text(zf, ncx_path)
root = ET.fromstring(text)
chapters: list[dict] = []
for nav in root.findall("ncx:navMap/ncx:navPoint", NS):
title = (nav.findtext("ncx:navLabel/ncx:text", default="", namespaces=NS) or "").strip()
content = nav.find("ncx:content", NS)
src = content.attrib.get("src", "") if content is not None else ""
# Strip the anchor — we want the file path only.
href = src.split("#", 1)[0]
chapters.append({"title": title, "href": href})
return chapters
def _resolve_zip_path(base_dir: str, href: str) -> str:
if not base_dir:
return href
return f"{base_dir}/{href}".lstrip("/")
def _extract_paragraphs(zf: zipfile.ZipFile, zip_path: str) -> list[str]:
try:
html = _read_zip_text(zf, zip_path)
except KeyError:
return []
soup = BeautifulSoup(html, "lxml")
paragraphs: list[str] = []
# Walk <p> and <li> in document order so vocab bullets (rendered as
# <ul><li>...</li></ul> in this EPUB family) are kept alongside narrative
# paragraphs. `<li>` rolls up its inline <b>/<span> children via get_text.
for el in soup.find_all(["p", "li"]):
text = _normalise(el.get_text(" ", strip=True))
if not text:
continue
paragraphs.append(text)
return paragraphs
def _chapter_files(
spine_files: list[str], chapter_hrefs: list[str]
) -> list[list[str]]:
"""Slice the spine into one list of files per chapter, using the chapter's
first href as the chapter boundary. Files before the first chapter (e.g.
cover, titlepage) are dropped."""
boundaries: list[int] = []
for href in chapter_hrefs:
try:
idx = spine_files.index(href)
except ValueError:
boundaries.append(-1)
continue
boundaries.append(idx)
ranges: list[list[str]] = []
for i, start in enumerate(boundaries):
if start < 0:
ranges.append([])
continue
end = len(spine_files)
for next_start in boundaries[i + 1:]:
if next_start >= 0:
end = next_start
break
ranges.append(spine_files[start:end])
return ranges
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("epub", type=Path)
parser.add_argument("--slug", default=None)
parser.add_argument("--out", type=Path, default=None)
args = parser.parse_args()
if not args.epub.exists():
print(f"EPUB not found: {args.epub}", file=sys.stderr)
sys.exit(2)
with zipfile.ZipFile(args.epub) as zf:
opf_path = _container_root(zf)
opf = _parse_opf(zf, opf_path)
if not opf["ncx_href"]:
print("No NCX found in spine; cannot derive chapter structure.", file=sys.stderr)
sys.exit(3)
ncx_path = _resolve_zip_path(opf["opf_dir"], opf["ncx_href"])
toc = _parse_ncx(zf, ncx_path)
spine_files = [
_resolve_zip_path(opf["opf_dir"], opf["manifest"].get(idref, ""))
for idref in opf["spine"]
]
chapter_hrefs = [_resolve_zip_path(opf["opf_dir"], c["href"]) for c in toc]
chapter_file_ranges = _chapter_files(spine_files, chapter_hrefs)
chapters_out: list[dict] = []
for i, (meta, files) in enumerate(zip(toc, chapter_file_ranges), start=1):
paragraphs: list[str] = []
for f in files:
paragraphs.extend(_extract_paragraphs(zf, f))
# Drop leading paragraph(s) that just echo the chapter title — the
# title is already stored separately.
title_norm = _normalise(meta["title"]).lower()
while paragraphs and _normalise(paragraphs[0]).lower() == title_norm:
paragraphs.pop(0)
chapters_out.append(
{
"id": f"ch{i}",
"number": i,
"title": meta["title"],
"paragraphsES": paragraphs,
}
)
slug = args.slug or _slugify(opf["title"]) or args.epub.stem
out_dir = args.out or (Path("build") / slug)
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / "chapters.json"
payload = {
"title": opf["title"],
"author": opf["author"],
"language": opf["language"],
"slug": slug,
"chapters": chapters_out,
}
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
total_paragraphs = sum(len(c["paragraphsES"]) for c in chapters_out)
print(f"Wrote {out_path}")
print(f" Title: {opf['title']}")
print(f" Author: {opf['author']}")
print(f" Chapters: {len(chapters_out)}")
print(f" Paragraphs: {total_paragraphs}")
for ch in chapters_out:
print(f" ch{ch['number']:02d} {len(ch['paragraphsES']):4d}{ch['title']}")
if __name__ == "__main__":
main()
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Orchestrate the books pipeline: EPUB -> chapters.json -> per-chapter job
# manifest -> (translation by Claude Code subagents) -> bundled book_<slug>.json.
#
# This script DOES NOT run the LLM translation pass. After Phase 2 it stops
# and prints how many jobs are pending. Use Claude Code subagents (or a fresh
# session per the README) to fill in build/<slug>/jobs/*.output.json, then
# re-run this script — it will pick up where it left off via Phase 3.
#
# Usage:
# ./run.sh <epub_path> [--slug SLUG] [--batch-size N]
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$HERE"
if [[ $# -lt 1 ]]; then
echo "usage: $0 <epub_path> [--slug SLUG] [--batch-size N]"
exit 2
fi
EPUB="$1"; shift
SLUG=""
BATCH_SIZE="30"
GLOSSARY_BATCH_SIZE="150"
while [[ $# -gt 0 ]]; do
case "$1" in
--slug) SLUG="$2"; shift 2 ;;
--batch-size) BATCH_SIZE="$2"; shift 2 ;;
--glossary-batch-size) GLOSSARY_BATCH_SIZE="$2"; shift 2 ;;
*) echo "unknown option: $1" >&2; exit 2 ;;
esac
done
EPUB_ABS="$(cd "$(dirname "$EPUB")" && pwd)/$(basename "$EPUB")"
echo "=== Phase 1: extract_epub.py ==="
if [[ -n "$SLUG" ]]; then
python3 extract_epub.py "$EPUB_ABS" --slug "$SLUG"
else
python3 extract_epub.py "$EPUB_ABS"
fi
# If --slug wasn't passed, recover the slug from the chapters file just written.
if [[ -z "$SLUG" ]]; then
SLUG=$(python3 -c "import json,glob; p=sorted(glob.glob('build/*/chapters.json'), key=lambda x: -__import__('os').path.getmtime(x))[0]; print(json.load(open(p))['slug'])")
fi
echo
echo "=== Phase 2: translate_chapters.py ==="
python3 translate_chapters.py "$SLUG" --batch-size "$BATCH_SIZE"
PENDING_FILE="build/$SLUG/jobs/_pending.txt"
PENDING_COUNT=$(wc -l < "$PENDING_FILE" | tr -d ' ')
echo
echo "=== Phase 2b: build_glossary.py ==="
python3 build_glossary.py "$SLUG" --batch-size "$GLOSSARY_BATCH_SIZE"
GLOSS_PENDING_FILE="build/$SLUG/glossary/_pending.txt"
GLOSS_PENDING_COUNT=$(wc -l < "$GLOSS_PENDING_FILE" | tr -d ' ')
TOTAL_PENDING=$((PENDING_COUNT + GLOSS_PENDING_COUNT))
echo
echo "=== Phase 3: bundle_book.py ==="
if [[ "$TOTAL_PENDING" -gt 0 ]]; then
echo " $PENDING_COUNT translation job(s) and $GLOSS_PENDING_COUNT glossary job(s) still pending."
echo " Run the Claude Code subagent step (see README.md) for BOTH manifests:"
echo " build/$SLUG/jobs/_pending.txt (translation)"
echo " build/$SLUG/glossary/_pending.txt (glossary)"
echo " then re-run this script. Bundling with placeholders so you can preview now."
python3 bundle_book.py "$SLUG"
else
python3 bundle_book.py "$SLUG" --require-all
fi
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Split chapters.json into translation jobs that Claude Code subagents can
process in parallel. Resumable: jobs whose output file already exists are
skipped.
Usage:
python3 translate_chapters.py <slug> [--batch-size N] [--build BUILD_DIR]
Inputs:
BUILD_DIR/<slug>/chapters.json (from extract_epub.py)
Outputs:
BUILD_DIR/<slug>/jobs/<jobid>.input.json (one per batch — read by subagents)
BUILD_DIR/<slug>/jobs/_pending.txt (list of job IDs still missing output)
BUILD_DIR/<slug>/jobs/_prompt_template.md (prompt the orchestrator hands each subagent)
Job layout (.input.json):
{
"jobId": "ch06_b00",
"chapter": 6,
"chapterTitle": "1. El Castillo",
"rangeStart": 0,
"rangeEnd": 30,
"paragraphsES": ["...", "..."]
}
Subagents must write `<jobid>.output.json` with shape:
{"jobId": "ch06_b00", "paragraphsEN": ["...", "..."]}
The output array MUST have the same length as paragraphsES, in the same order.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
PROMPT_TEMPLATE = """\
You are translating a chunk of a Spanish-language book into English for a
language-learning app.
Input file: {input_path}
Output file: {output_path}
Read the input file. It contains a JSON object with a `paragraphsES` array.
Translate each paragraph into natural English. Preserve meaning, tone, and
dialogue markers (—, , ¡, ¿) as appropriate for the English output. Keep
the same number of paragraphs in the same order.
Notes for translation quality:
- This is a beginner Spanish reader, so prefer plain natural English over
literary flourish.
- Preserve proper nouns (character names, place names) verbatim.
- Convert Spanish dialogue dashes (, —) to English-style quotation marks
ONLY if it reads more naturally; otherwise keep them as em-dashes.
- Do NOT add explanatory parentheticals; the in-app dictionary handles
per-word lookup.
- Some paragraphs are vocabulary entries shaped like `palabra = meaning`
(e.g. `alto = tall`, `el dueño = owner`). Keep these verbatim — both the
Spanish word and its English gloss already coexist on the line, and the
bilingual reader UI shows the same line in both views.
Write the output as JSON with shape:
{{"jobId": "<the jobId from the input>", "paragraphsEN": [...]}}
The `paragraphsEN` array MUST be the same length and order as `paragraphsES`
in the input. Write nothing else to disk and produce no other output.
"""
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("slug")
parser.add_argument("--batch-size", type=int, default=30)
parser.add_argument("--build", type=Path, default=Path("build"))
args = parser.parse_args()
base = args.build / args.slug
chapters_path = base / "chapters.json"
jobs_dir = base / "jobs"
jobs_dir.mkdir(parents=True, exist_ok=True)
data = json.loads(chapters_path.read_text(encoding="utf-8"))
pending: list[str] = []
completed: list[str] = []
total_jobs = 0
for ch in data["chapters"]:
paragraphs = ch["paragraphsES"]
if not paragraphs:
continue
for offset in range(0, len(paragraphs), args.batch_size):
chunk = paragraphs[offset : offset + args.batch_size]
job_id = f"ch{ch['number']:02d}_b{offset // args.batch_size:02d}"
input_path = jobs_dir / f"{job_id}.input.json"
output_path = jobs_dir / f"{job_id}.output.json"
input_path.write_text(
json.dumps(
{
"jobId": job_id,
"chapter": ch["number"],
"chapterTitle": ch["title"],
"rangeStart": offset,
"rangeEnd": offset + len(chunk),
"paragraphsES": chunk,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
total_jobs += 1
if output_path.exists():
completed.append(job_id)
else:
pending.append(job_id)
(jobs_dir / "_pending.txt").write_text("\n".join(pending) + ("\n" if pending else ""))
(jobs_dir / "_prompt_template.md").write_text(
PROMPT_TEMPLATE.format(
input_path="<JOB_INPUT_PATH>",
output_path="<JOB_OUTPUT_PATH>",
),
encoding="utf-8",
)
print(f"Total translation jobs: {total_jobs}")
print(f" Completed: {len(completed)}")
print(f" Pending: {len(pending)}")
print(f"Manifest at: {jobs_dir / '_pending.txt'}")
print(f"Prompt template at: {jobs_dir / '_prompt_template.md'}")
if __name__ == "__main__":
main()
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""Generate a markdown report of every curated YouTube video referenced by the app.
Reads Conjuga/youtube_videos.json, queries yt-dlp for metadata on each video,
and emits Conjuga/youtube_videos.md with tables for tense guides and grammar
notes plus a list of topics with no curated video.
Usage:
python3 Scripts/generate_videos_markdown.py
Requires `yt-dlp` on PATH. Videos that have been taken down or made private
appear in the tables with an "(unavailable)" marker in the title column.
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import date
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
VIDEOS_JSON = REPO_ROOT / "Conjuga" / "youtube_videos.json"
OUTPUT_MD = REPO_ROOT / "Conjuga" / "youtube_videos.md"
# The curated ids we expect — anything in the source file that's missing from
# the JSON shows up in the "missing" section at the bottom.
EXPECTED_TENSE_IDS = [
"ind_presente", "ind_preterito", "ind_imperfecto", "ind_futuro",
"ind_perfecto", "ind_pluscuamperfecto", "ind_futuro_perfecto",
"ind_preterito_anterior",
"cond_presente", "cond_perfecto",
"subj_presente", "subj_imperfecto_1", "subj_imperfecto_2",
"subj_perfecto", "subj_pluscuamperfecto_1", "subj_pluscuamperfecto_2",
"subj_futuro", "subj_futuro_perfecto",
"imp_afirmativo", "imp_negativo",
]
EXPECTED_GRAMMAR_IDS = [
"ser-vs-estar", "por-vs-para", "preterite-vs-imperfect",
"subjunctive-triggers", "reflexive-verbs", "object-pronouns",
"gustar-like-verbs", "comparatives-superlatives",
"conditional-if-clauses", "commands-imperative", "saber-vs-conocer",
"double-negatives", "adjective-placement", "tener-expressions",
"personal-a", "relative-pronouns", "future-vs-ir-a",
"accent-marks-stress", "se-constructions", "estar-gerund-progressive",
"spanish-suffixes", "common-irregular-verbs", "types-of-irregular-verbs",
"present-indicative-conjugation", "articles-and-gender",
"possessive-adjectives", "demonstrative-adjectives",
"greetings-farewells", "poder-infinitive", "al-del-contractions",
"prepositional-pronouns", "irregular-yo-verbs", "stem-changing-verbs",
"stressed-possessives", "present-perfect-tense", "future-perfect-tense",
]
def fetch_metadata(video_id: str) -> dict:
"""Return a dict of useful metadata fields for a single video.
On any yt-dlp failure (video removed, network issue, extraction break)
returns a dict with `unavailable=True` so the caller can mark the row.
"""
try:
result = subprocess.run(
["yt-dlp", "--skip-download", "--dump-json", "--no-warnings", "--", video_id],
capture_output=True,
text=True,
timeout=30,
)
except subprocess.TimeoutExpired:
return {"unavailable": True, "reason": "timeout"}
if result.returncode != 0:
# yt-dlp errors look like:
# "ERROR: [youtube] ID: <reason>. <cookie/help nag with URLs…>"
# Extract just <reason> and drop everything after the first "." so the
# markdown table stays readable. Help URLs contain colons so a naive
# split-on-colon grabs the wrong chunk.
reason = "yt-dlp failed"
pattern = re.compile(r"ERROR:\s*\[[^\]]+\]\s*[^:]+:\s*(.+)")
for line in result.stderr.strip().splitlines():
m = pattern.search(line)
if m:
reason = m.group(1).split(". ")[0].rstrip(".")
break
return {"unavailable": True, "reason": reason}
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
return {"unavailable": True, "reason": "invalid json"}
return {
"unavailable": False,
"title": data.get("title") or "",
"uploader": data.get("uploader") or data.get("channel") or "",
"upload_date": data.get("upload_date") or "", # YYYYMMDD
"duration": data.get("duration"), # seconds
"view_count": data.get("view_count"),
"like_count": data.get("like_count"),
}
def fmt_duration(seconds: int | None) -> str:
if not seconds:
return ""
h, rem = divmod(int(seconds), 3600)
m, s = divmod(rem, 60)
if h:
return f"{h}:{m:02d}:{s:02d}"
return f"{m}:{s:02d}"
def fmt_date(raw: str) -> str:
if not raw or len(raw) != 8:
return ""
return f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]}"
def fmt_int(n: int | None) -> str:
if n is None:
return ""
return f"{n:,}"
def render_row(key: str, curated: dict, meta: dict) -> str:
video_id = curated["videoId"]
url = f"https://www.youtube.com/watch?v={video_id}"
if meta.get("unavailable"):
title = f"_(unavailable — {meta.get('reason', 'unknown')})_"
channel = ""
uploaded = ""
duration = ""
views = ""
likes = ""
else:
title = meta.get("title") or curated.get("title") or ""
# Escape pipes in titles so table rendering doesn't break.
title = title.replace("|", "\\|")
channel = (meta.get("uploader") or "").replace("|", "\\|")
uploaded = fmt_date(meta.get("upload_date", ""))
duration = fmt_duration(meta.get("duration"))
views = fmt_int(meta.get("view_count"))
likes = fmt_int(meta.get("like_count"))
return f"| `{key}` | {title} | {channel} | {uploaded} | {duration} | {views} | {likes} | [watch]({url}) |"
def main() -> int:
with VIDEOS_JSON.open() as f:
data = json.load(f)
tense_entries = data.get("tenseGuides", {})
grammar_entries = data.get("grammarNotes", {})
# Collect all unique videoIds so we only call yt-dlp once per video
# (several grammar notes reuse tense-guide videos).
video_ids = {e["videoId"] for e in tense_entries.values()} | {
e["videoId"] for e in grammar_entries.values()
}
print(f"Fetching metadata for {len(video_ids)} unique videos…", file=sys.stderr)
metadata: dict[str, dict] = {}
with ThreadPoolExecutor(max_workers=8) as pool:
future_to_id = {pool.submit(fetch_metadata, vid): vid for vid in video_ids}
for future in as_completed(future_to_id):
vid = future_to_id[future]
metadata[vid] = future.result()
status = "" if metadata[vid].get("unavailable") else ""
print(f" {status} {vid}", file=sys.stderr)
missing_tenses = [tid for tid in EXPECTED_TENSE_IDS if tid not in tense_entries]
missing_grammar = [gid for gid in EXPECTED_GRAMMAR_IDS if gid not in grammar_entries]
today = date.today().isoformat()
lines: list[str] = []
lines.append("# Curated YouTube Videos")
lines.append("")
lines.append(
"Every tense guide and grammar note in the app can be tied to a single "
"curated YouTube video. This file is generated from "
"`Conjuga/youtube_videos.json` by `Scripts/generate_videos_markdown.py` "
"— regenerate when you add or change entries."
)
lines.append("")
lines.append(f"- Total tense-guide entries: **{len(tense_entries)}** of {len(EXPECTED_TENSE_IDS)}")
lines.append(f"- Total grammar-note entries: **{len(grammar_entries)}** of {len(EXPECTED_GRAMMAR_IDS)}")
lines.append(f"- Last verified: **{today}** (run `python3 Scripts/generate_videos_markdown.py` to refresh)")
lines.append("")
lines.append(
"Like counts are often blank because YouTube hides the public count on "
"most videos for signed-out requests. Titles and durations are pulled "
"live from YouTube; unavailable entries mean the video has been taken "
"down, made private, or region-locked. A few entries marked "
"\"not available on this app\" are a transient yt-dlp extraction limit "
"— the video itself still plays fine when tapping Stream in the app."
)
lines.append("")
# Tense guides section
lines.append("## Tense guides")
lines.append("")
lines.append("Tied to `TenseGuide.tenseId` in the Guide tab.")
lines.append("")
lines.append("| Tense ID | Title | Channel | Uploaded | Duration | Views | Likes | URL |")
lines.append("|---|---|---|---|---|---|---|---|")
for tid in EXPECTED_TENSE_IDS:
if tid not in tense_entries:
continue
entry = tense_entries[tid]
lines.append(render_row(tid, entry, metadata.get(entry["videoId"], {})))
lines.append("")
# Grammar notes section
lines.append("## Grammar notes")
lines.append("")
lines.append("Tied to `GrammarNote.id` (hand-authored + generated) in the Guide → Grammar tab.")
lines.append("")
lines.append("| Grammar ID | Title | Channel | Uploaded | Duration | Views | Likes | URL |")
lines.append("|---|---|---|---|---|---|---|---|")
for gid in EXPECTED_GRAMMAR_IDS:
if gid not in grammar_entries:
continue
entry = grammar_entries[gid]
lines.append(render_row(gid, entry, metadata.get(entry["videoId"], {})))
lines.append("")
# Missing section
if missing_tenses or missing_grammar:
lines.append("## Topics without a curated video")
lines.append("")
lines.append(
"These show a \"No video yet\" label in the app. Add entries to "
"`Conjuga/youtube_videos.json` to fill them in."
)
lines.append("")
if missing_tenses:
lines.append("**Tense guides:**")
lines.append("")
for tid in missing_tenses:
lines.append(f"- `{tid}`")
lines.append("")
if missing_grammar:
lines.append("**Grammar notes:**")
lines.append("")
for gid in missing_grammar:
lines.append(f"- `{gid}`")
lines.append("")
OUTPUT_MD.write_text("\n".join(lines))
print(f"\nWrote {OUTPUT_MD.relative_to(REPO_ROOT)}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,2 @@
in/
out/
+119
View File
@@ -0,0 +1,119 @@
# Guide enrichment plan
**Trigger**: WEIRDO was missing from the present-subjunctive guide. That's a perfect example of a deeper problem — most tense guides are surface-level reference cards (2-3 usages + examples), missing the mnemonics, contrast tables, and exception lists a real Spanish teacher would hand out.
**Goal**: bring every tense guide and grammar note up to "teacher-handout" depth — enough that a learner could study from it alone and pass a quiz.
## Current state (audit, 2026-05-11)
| Surface | Items | Source of truth | Typical body length | Verdict |
|---|---|---|---|---|
| Tense guides | 20 | `Conjuga/Conjuga/conjuga_data.json``tenseGuides[]` | 5001500 chars | **Shallow** — bare *Usages* + examples |
| Grammar notes | ~36 | `Conjuga/Conjuga/Models/GrammarNote.swift` (`GrammarNote.allNotes`, `generatedNotes`) | 15003000 chars | **Decent** — most have mnemonics and contrast examples |
| Reference store | — | `Conjuga/Conjuga/Services/ReferenceStore.swift` | varies | Not in scope for this pass |
Tense guides are the bulk of the work. Grammar notes need a smaller audit-and-fill pass.
## What "thorough" looks like
Every tense guide should include, at minimum:
1. **Quick TL;DR** — one sentence: what is this tense for?
2. **When to use it** — numbered usages, each with 2 contrast examples (a clear case and a borderline / common-mistake case).
3. **How to form it** — conjugation pattern for regular verbs (one table per AR/ER/IR if it differs), plus the irregular pattern callout if applicable. Cross-reference the conjugator screens if relevant.
4. **Common irregulars** — top 510 irregular verbs that learners will hit immediately in this tense (ser, estar, ir, tener, haber, dar, ver, decir, hacer, querer, poder, poner, saber, salir, traer, venir).
5. **Triggers / mnemonics** — words and structures that signal this tense. WEIRDO and ESCAPA for subjunctive; "yesterday / last X / specific time" for preterite; "used to / when I was a kid" for imperfect; etc.
6. **Pitfalls** — the top 35 mistakes English speakers make. e.g. preterite vs imperfect mixups, ir vs venir, ser vs estar overlap.
7. **Tense-vs-tense contrast** — pair with the closest neighbour and show 2 minimal pairs (preterite ↔ imperfect, present ↔ present-progressive, future ↔ ir-a + infinitive, subjunctive-presente ↔ subjunctive-imperfecto).
8. **Real-world feel** — 23 dialogue-style examples showing the tense in natural use, not just isolated sentences.
Every grammar note should include, at minimum:
1. The core distinction in one line.
2. Each side of the distinction with 46 clear examples covering different positions in a sentence.
3. A mnemonic if one is standard in the language (DOCTOR/PLACE, WEIRDO, ESCAPA, etc.).
4. Edge cases / verbs that change meaning (e.g. ser/estar adjectives, conocer/saber overlap).
5. A practice prompt: "Try translating these 3 sentences, then check below."
## Priority order
Triaged by learner impact (frequency of use × typical confusion):
**Tier 1 — most-used, most-confused** (do first):
1. `ind_presente` (Present indicative) — already 1324 chars, the longest tense guide. Audit for gaps; probably needs irregular tables.
2. `ind_preterito` (Preterite) — currently 492 chars, the shortest. **Highest priority** — every learner hits this and gets it wrong.
3. `ind_imperfecto` (Imperfect) — 774 chars. Always taught alongside preterite; the contrast is the entire game.
4. `subj_presente` (Present subjunctive) — ✅ done in this pass.
5. `imp_afirmativo` + `imp_negativo` (Imperative pair) — combined 2037 chars. Needs the tú/usted/nosotros/vosotros table and the negative-flips-to-subjunctive rule highlighted.
**Tier 2 — common but often skimped**:
6. `ind_futuro` (Simple future) — needs contrast with ir-a + infinitive (already covered in grammar notes; cross-link).
7. `cond_presente` (Conditional) — needs the "if-clause" patterns and the "softening request" usage ("¿Podrías…?").
8. `ind_perfecto` (Present perfect) — needs the haber + past participle conjugation table and the "ya / todavía / alguna vez" trigger words.
9. `subj_imperfecto_1` + `subj_imperfecto_2` (Past subjunctive -ra / -se) — needs the if-clause + condicional pairing.
**Tier 3 — compound and less-frequent** (still must be thorough):
10. `ind_pluscuamperfecto`, `ind_futuro_perfecto`, `ind_preterito_anterior` (literary)
11. `cond_perfecto`, `subj_perfecto`, `subj_pluscuamperfecto_1`, `subj_pluscuamperfecto_2`
12. `subj_futuro`, `subj_futuro_perfecto` (largely archaic — note they're rare but explain why they exist)
**Grammar notes audit**:
- Pass through all 36, score each on the "thorough" criteria above.
- Fill the gaps. Most already have mnemonics; some don't.
## Research sources
Cite explicitly in each draft so reviewers can verify. Order of trust:
1. **Real Academia Española (RAE) — Nueva gramática de la lengua española** — authoritative reference. Free online: `rae.es`.
2. **Studyspanish.com** and **SpanishDict.com** grammar references — best free per-topic explanations, well-curated example sentences.
3. **Practice Makes Perfect: Complete Spanish Grammar** (Dorothy Richmond, McGraw-Hill) — standard teaching reference. The PDF is already at the repo root for cross-reference.
4. **Lawless Spanish** (Laura Lawless) — accurate, concise, good on subjunctive nuances.
5. **The user's existing textbook***Complete Spanish Step-by-Step* (Bregstein) is already bundled. Cross-reference its chapter on each tense to keep voice consistent.
6. **YouTube — Butterfly Spanish (Ana), Spring Spanish, Dreaming Spanish (Pablo)** — for natural-use examples and the "feel" of when a native reaches for the tense. The repo already has a curated YouTube list at `Conjuga/Conjuga/youtube_videos.json` — pull from there when a topic has a matching video.
For mnemonics specifically: WEIRDO, ESCAPA, DOCTOR, PLACE are standard. Don't invent new ones unless we can't find a known one.
## Workflow per topic
This is what an enrichment "unit of work" looks like:
1. **Draft** — A research agent (Claude Code subagent, no API key, same pattern as the book translation pipeline) reads the current guide body, consults the sources listed above, drafts a new body following the "thorough" structure. Writes to `Conjuga/Scripts/guide-enrichment/drafts/<topicId>.md`.
2. **Self-review** — same agent re-reads its own draft against the checklist (TL;DR present? mnemonic present? contrast pair? top 3 pitfalls?). Notes anything it couldn't find a source for.
3. **Integrate** — a script reads the draft, swaps it into `conjuga_data.json` (for tense guides) or `GrammarNote.swift` (for grammar notes), bumps `courseDataVersion`, runs build to verify.
4. **Spot-check** — user opens the topic in the app on device, reads it, flags anything that feels wrong or missing.
5. **Commit** — one commit per topic, message: "Guide enrichment — <topic> (tier N)".
Batching: do tier-1 topics one at a time so the user can review and shape what "thorough enough" looks like. Tiers 2 and 3 can batch 35 topics per session once the format is dialed in.
## Tooling
Two small scripts will speed this up:
- **`enrich_topic.py <topicId>`** — opens the current body, writes a Markdown template at `drafts/<topicId>.md` with the section headers pre-filled, and prints a research prompt the user can hand to a subagent.
- **`apply_draft.py <topicId>`** — reads `drafts/<topicId>.md`, validates the section structure, swaps it into `conjuga_data.json` (or `GrammarNote.swift` for grammar notes), bumps `courseDataVersion`.
Build both when starting tier 1. Don't build them speculatively now.
## Effort estimate
- Tier 1 (5 topics): ~30 min research + 30 min draft + 15 min integrate = **~75 min per topic, ~6 hours total**.
- Tier 2 (4 topics): faster once the format is dialed in. ~45 min each, ~3 hours.
- Tier 3 (11 topics): ~30 min each (most are compound tenses with similar structure), ~5 hours.
- Grammar notes audit + fill: ~10 min audit each × 36 = 6 hours; ~30 min fill on the ~10 that need it = 5 hours. Total ~11 hours.
**Total scoped at ~25 hours.** Spread across sessions: maybe one tier-1 topic per session, two tier-2 or three tier-3 per session once the format's locked in.
## Ship plan
- Each commit is one topic enriched. Small, reviewable diffs.
- `courseDataVersion` bumps per commit so the change propagates on next launch.
- The user can preview new bodies via the in-app Guide tab without needing a redeploy after the commit hits gitea — they just need to rebuild + reinstall.
- The plan doc itself lives here so future sessions can pick up where this one left off without needing to re-derive the structure.
## Out of scope (intentional)
- Audio recordings of example sentences (could be a future TTS pre-bake).
- Per-region variants (Latin American vs Castilian usage notes) — flag when they matter (vosotros, leísmo), don't comprehensively document.
- Interactive exercises tied to each guide (separate Tests/Quiz infrastructure exists; cross-link instead of duplicate).
- Translation of the guides into Spanish (current guides are English-explanation, Spanish-examples; keep that asymmetry).
- A complete grammar-textbook rewrite. Stop at "depth a teacher would hand out as supplementary material."
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Apply enriched bodies from drafts/out/ back into the live source files.
Tense guides → Conjuga/Conjuga/conjuga_data.json (tenseGuides[].body)
Grammar notes → Conjuga/Conjuga/Models/GrammarNote.swift (body: \"\"\"...\"\"\")
Filename conventions in drafts/out/:
tense__<tenseId>.md — body to drop into the matching tenseGuide
note__<noteId>.md — body to drop into the matching GrammarNote(...) declaration
Run from anywhere; uses absolute paths anchored at the repo root.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
REPO = Path('/Users/m4mini/Desktop/code/Spanish')
OUT_DIR = REPO / 'Conjuga/Scripts/guide-enrichment/out'
TENSE_JSON = REPO / 'Conjuga/Conjuga/conjuga_data.json'
NOTES_SWIFT = REPO / 'Conjuga/Conjuga/Models/GrammarNote.swift'
def read_draft(path: Path) -> str:
"""Drafts may start with comment blocks like `# Title: ...`, `# Category:
...`, and `# ENRICHED BODY` separated by blank lines. Strip every leading
line that is blank or starts with `#` until we reach the actual body."""
raw = path.read_text(encoding='utf-8')
lines = raw.splitlines()
start = 0
for i, line in enumerate(lines):
stripped = line.strip()
if stripped == '' or stripped.startswith('#'):
start = i + 1
continue
# Hit the first real content line — keep everything from here.
start = i
break
body = '\n'.join(lines[start:]).strip()
if not body:
raise ValueError(f'Empty body after stripping header in {path}')
return body
def apply_tense_guides() -> int:
data = json.loads(TENSE_JSON.read_text(encoding='utf-8'))
drafts = sorted(OUT_DIR.glob('tense__*.md'))
by_id = {g['tenseId']: g for g in data['tenseGuides']}
applied = 0
for path in drafts:
tense_id = path.stem.removeprefix('tense__')
if tense_id not in by_id:
print(f' SKIP {tense_id}: not in tenseGuides', file=sys.stderr)
continue
body = read_draft(path)
by_id[tense_id]['body'] = body
applied += 1
print(f' applied tense: {tense_id} ({len(body)} chars)')
TENSE_JSON.write_text(
json.dumps(data, ensure_ascii=False, separators=(',', ':')),
encoding='utf-8'
)
return applied
# Match each GrammarNote(...) declaration. Body uses """...""" — may contain
# anything except a triple-quote.
NOTE_PATTERN = re.compile(
r'(GrammarNote\(\s*id:\s*"([^"]+)",\s*'
r'title:\s*"(?:[^"\\]|\\.)*",\s*'
r'category:\s*"[^"]+",\s*'
r'body:\s*""")(.*?)(""")',
re.DOTALL
)
def apply_grammar_notes() -> int:
src = NOTES_SWIFT.read_text(encoding='utf-8')
drafts = sorted(OUT_DIR.glob('note__*.md'))
by_id = {p.stem.removeprefix('note__'): p for p in drafts}
applied = [0]
def replace_match(m):
prefix, note_id, _, suffix = m.group(1), m.group(2), m.group(3), m.group(4) if m.lastindex and m.lastindex >= 4 else m.group(3)
return m.group(0) # placeholder, see real callback below
def real_replace(m):
prefix = m.group(1)
note_id = m.group(2)
suffix = m.group(4)
if note_id not in by_id:
return m.group(0)
body = read_draft(by_id[note_id])
if '"""' in body:
raise ValueError(f'Body for {note_id} contains triple-quote — would break Swift parser')
# Re-indent to match the existing Swift block. The existing format uses
# 8 spaces of leading indent inside body lines. We don't enforce that —
# the Swift compiler handles multiline string indentation by stripping
# the leading whitespace common to all lines based on the closing """.
# Just write the body verbatim.
applied[0] += 1
print(f' applied note: {note_id} ({len(body)} chars)')
return f'{prefix}\n{body}\n{suffix}'
new_src = NOTE_PATTERN.sub(real_replace, src)
NOTES_SWIFT.write_text(new_src, encoding='utf-8')
return applied[0]
def main():
if not OUT_DIR.exists():
print(f'No drafts/out directory at {OUT_DIR}', file=sys.stderr)
sys.exit(1)
print(f'=== Tense guides ===')
tense_count = apply_tense_guides()
print(f'\n=== Grammar notes ===')
note_count = apply_grammar_notes()
print(f'\nTotal applied: {tense_count} tense guides + {note_count} grammar notes')
if tense_count == 0 and note_count == 0:
print('Nothing applied — drafts/out/ was empty.', file=sys.stderr)
sys.exit(2)
if __name__ == '__main__':
main()
+374
View File
@@ -0,0 +1,374 @@
#!/usr/bin/env python3
"""Merge chapters.json + answers.json + ocr.json → book.json (single source).
Also emits vocab_cards.json: flashcards derived from vocab_image blocks where
OCR text parses as a clean two-column (Spanish ↔ English) table.
"""
import json
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
CHAPTERS_JSON = HERE / "chapters.json"
ANSWERS_JSON = HERE / "answers.json"
OCR_JSON = HERE / "ocr.json"
OUT_BOOK = HERE / "book.json"
OUT_VOCAB = HERE / "vocab_cards.json"
COURSE_NAME = "Complete Spanish Step-by-Step"
# Heuristic: parseable "Spanish | English" vocab rows.
# OCR usually produces "word — translation" or "word translation" separated
# by 2+ spaces. We detect rows that contain both Spanish and English words.
SPANISH_ACCENT_RE = re.compile(r"[áéíóúñüÁÉÍÓÚÑÜ¿¡]")
SPANISH_ARTICLES = {"el", "la", "los", "las", "un", "una", "unos", "unas"}
ENGLISH_STARTERS = {"the", "a", "an", "to", "my", "his", "her", "our", "their", "your", "some"}
# English-only words that would never appear as Spanish
ENGLISH_ONLY_WORDS = {"the", "he", "she", "it", "we", "they", "I", "is", "are", "was", "were",
"been", "have", "has", "had", "will", "would", "should", "could"}
SEP_RE = re.compile(r"[ \t]{2,}|\s[—–−-]\s")
def classify_line(line: str) -> str:
"""Return 'es', 'en', or 'unknown' for the dominant language of a vocab line."""
line = line.strip()
if not line:
return "unknown"
# Accent = definitely Spanish
if SPANISH_ACCENT_RE.search(line):
return "es"
first = line.split()[0].lower().strip(",.;:")
if first in SPANISH_ARTICLES:
return "es"
if first in ENGLISH_STARTERS:
return "en"
# Check if the leading word is an English-only function word
if first in ENGLISH_ONLY_WORDS:
return "en"
return "unknown"
def looks_english(word: str) -> bool:
"""Legacy helper — kept for try_split_row below."""
w = word.lower().strip()
if not w:
return False
if SPANISH_ACCENT_RE.search(w):
return False
if w in SPANISH_ARTICLES:
return False
if w in ENGLISH_STARTERS or w in ENGLISH_ONLY_WORDS:
return True
return bool(re.match(r"^[a-z][a-z\s'/()\-,.]*$", w))
def try_split_row(line: str) -> "tuple[str, str] | None":
"""Split a line into (spanish, english) if it looks like a vocab entry."""
line = line.strip()
if not line or len(line) < 3:
return None
# Try explicit separators first
parts = SEP_RE.split(line)
parts = [p.strip() for p in parts if p.strip()]
if len(parts) == 2:
spanish, english = parts
if looks_english(english) and not looks_english(spanish.split()[0]):
return (spanish, english)
return None
def load(p: Path) -> dict:
return json.loads(p.read_text(encoding="utf-8"))
def build_vocab_cards_for_block(block: dict, ocr_entry: dict, chapter: dict, context_title: str, idx: int) -> list:
"""Given a vocab_image block + its OCR lines, derive flashcards.
Vision OCR reads top-to-bottom, left-to-right; a two-column vocab table
produces Spanish lines first, then English lines. We split the list in
half when one side is predominantly Spanish and the other English.
Per-line '' separators are also supported as a fallback.
"""
cards = []
if not ocr_entry:
return cards
lines = [l.strip() for l in ocr_entry.get("lines", []) if l.strip()]
if not lines:
return cards
def card(front: str, back: str) -> dict:
return {
"front": front,
"back": back,
"chapter": chapter["number"],
"chapterTitle": chapter["title"],
"section": context_title,
"sourceImage": block["src"],
}
# Attempt 1: explicit inline separator (e.g. "la casa — the house")
inline = []
all_inline = True
for line in lines:
pair = try_split_row(line)
if pair:
inline.append(pair)
else:
all_inline = False
break
if all_inline and inline:
for es, en in inline:
cards.append(card(es, en))
return cards
# Attempt 2: block-alternating layout.
# Vision OCR reads columns top-to-bottom, so a 2-col table rendered across
# 2 visual columns produces runs like: [ES...ES][EN...EN][ES...ES][EN...EN]
# We classify each line, smooth "unknown" using neighbors, then pair
# same-sized consecutive ES/EN blocks.
classes = [classify_line(l) for l in lines]
# Pass 1: fill unknowns using nearest non-unknown neighbor (forward)
last_known = "unknown"
forward = []
for c in classes:
if c != "unknown":
last_known = c
forward.append(last_known)
# Pass 2: backfill leading unknowns (backward)
last_known = "unknown"
backward = [""] * len(classes)
for i in range(len(classes) - 1, -1, -1):
if classes[i] != "unknown":
last_known = classes[i]
backward[i] = last_known
# Merge: prefer forward unless still unknown
resolved = []
for f, b in zip(forward, backward):
if f != "unknown":
resolved.append(f)
elif b != "unknown":
resolved.append(b)
else:
resolved.append("unknown")
# Group consecutive same-lang lines
blocks: list = []
cur_lang: "str | None" = None
cur_block: list = []
for line, lang in zip(lines, resolved):
if lang != cur_lang:
if cur_block and cur_lang is not None:
blocks.append((cur_lang, cur_block))
cur_block = [line]
cur_lang = lang
else:
cur_block.append(line)
if cur_block and cur_lang is not None:
blocks.append((cur_lang, cur_block))
# Walk blocks pairing ES then EN of equal length
i = 0
while i < len(blocks) - 1:
lang_a, lines_a = blocks[i]
lang_b, lines_b = blocks[i + 1]
if lang_a == "es" and lang_b == "en" and len(lines_a) == len(lines_b):
for es, en in zip(lines_a, lines_b):
cards.append(card(es, en))
i += 2
continue
# If reversed order (some pages have EN column on left), try that too
if lang_a == "en" and lang_b == "es" and len(lines_a) == len(lines_b):
for es, en in zip(lines_b, lines_a):
cards.append(card(es, en))
i += 2
continue
i += 1
return cards
def clean_instruction(text: str) -> str:
"""Strip leading/trailing emphasis markers from a parsed instruction."""
# Our XHTML parser emitted * and ** for emphasis; flatten them
t = re.sub(r"\*+", "", text)
return t.strip()
def merge() -> None:
chapters_data = load(CHAPTERS_JSON)
answers_data = load(ANSWERS_JSON)
try:
ocr_data = load(OCR_JSON)
except FileNotFoundError:
print("ocr.json not found — proceeding with empty OCR data")
ocr_data = {}
answers = answers_data["answers"]
chapters = chapters_data["chapters"]
parts = chapters_data.get("part_memberships", {})
book_chapters = []
all_vocab_cards = []
missing_ocr = set()
current_section_title = ""
for ch in chapters:
out_blocks = []
current_section_title = ch["title"]
for bi, block in enumerate(ch["blocks"]):
k = block["kind"]
if k == "heading":
current_section_title = block["text"]
out_blocks.append(block)
continue
if k == "paragraph":
out_blocks.append(block)
continue
if k == "key_vocab_header":
out_blocks.append(block)
continue
if k == "vocab_image":
ocr_entry = ocr_data.get(block["src"])
if ocr_entry is None:
missing_ocr.add(block["src"])
derived = build_vocab_cards_for_block(
block, ocr_entry, ch, current_section_title, bi
)
all_vocab_cards.extend(derived)
out_blocks.append({
"kind": "vocab_table",
"sourceImage": block["src"],
"ocrLines": ocr_entry.get("lines", []) if ocr_entry else [],
"ocrConfidence": ocr_entry.get("confidence", 0.0) if ocr_entry else 0.0,
"cardCount": len(derived),
})
continue
if k == "exercise":
ans = answers.get(block["id"])
image_ocr_lines = []
for src in block.get("image_refs", []):
e = ocr_data.get(src)
if e is None:
missing_ocr.add(src)
continue
image_ocr_lines.extend(e.get("lines", []))
# Build the final prompt list. If we have text prompts from
# XHTML, prefer them. Otherwise, attempt to use OCR lines.
prompts = [p for p in block.get("prompts", []) if p.strip()]
extras = [e for e in block.get("extra", []) if e.strip()]
if not prompts and image_ocr_lines:
# Extract numbered lines from OCR (look for "1. ..." pattern)
for line in image_ocr_lines:
m = re.match(r"^(\d+)[.)]\s*(.+)", line.strip())
if m:
prompts.append(f"{m.group(1)}. {m.group(2)}")
# Cross-reference prompts with answers
sub = ans["subparts"] if ans else []
answer_items = []
for sp in sub:
for it in sp["items"]:
answer_items.append({
"label": sp["label"],
"number": it["number"],
"answer": it["answer"],
"alternates": it["alternates"],
})
out_blocks.append({
"kind": "exercise",
"id": block["id"],
"ansAnchor": block.get("ans_anchor", ""),
"instruction": clean_instruction(block.get("instruction", "")),
"extra": extras,
"prompts": prompts,
"ocrLines": image_ocr_lines,
"freeform": ans["freeform"] if ans else False,
"answerItems": answer_items,
"answerRaw": ans["raw"] if ans else "",
"answerSubparts": sub,
})
continue
out_blocks.append(block)
book_chapters.append({
"id": ch["id"],
"number": ch["number"],
"title": ch["title"],
"part": ch.get("part"),
"blocks": out_blocks,
})
book = {
"courseName": COURSE_NAME,
"totalChapters": len(book_chapters),
"totalExercises": sum(
1 for ch in book_chapters for b in ch["blocks"] if b["kind"] == "exercise"
),
"totalVocabTables": sum(
1 for ch in book_chapters for b in ch["blocks"] if b["kind"] == "vocab_table"
),
"totalVocabCards": len(all_vocab_cards),
"parts": parts,
"chapters": book_chapters,
}
OUT_BOOK.write_text(json.dumps(book, ensure_ascii=False))
# Vocab cards as a separate file (grouped per chapter so they can be seeded
# as CourseDecks in the existing schema).
vocab_by_chapter: dict = {}
for card in all_vocab_cards:
vocab_by_chapter.setdefault(card["chapter"], []).append(card)
OUT_VOCAB.write_text(json.dumps({
"courseName": COURSE_NAME,
"chapters": [
{
"chapter": ch_num,
"cards": cards,
}
for ch_num, cards in sorted(vocab_by_chapter.items())
],
}, ensure_ascii=False, indent=2))
# Summary
print(f"Wrote {OUT_BOOK}")
print(f"Wrote {OUT_VOCAB}")
print(f"Chapters: {book['totalChapters']}")
print(f"Exercises: {book['totalExercises']}")
print(f"Vocab tables: {book['totalVocabTables']}")
print(f"Vocab cards (auto): {book['totalVocabCards']}")
if missing_ocr:
print(f"Missing OCR for {len(missing_ocr)} images (first 5): {sorted(list(missing_ocr))[:5]}")
# Validation
total_exercises = book["totalExercises"]
exercises_with_prompts = sum(
1 for ch in book_chapters for b in ch["blocks"]
if b["kind"] == "exercise" and (b["prompts"] or b["extra"])
)
exercises_with_answers = sum(
1 for ch in book_chapters for b in ch["blocks"]
if b["kind"] == "exercise" and b["answerItems"]
)
exercises_freeform = sum(
1 for ch in book_chapters for b in ch["blocks"]
if b["kind"] == "exercise" and b["freeform"]
)
print(f"Exercises with prompts: {exercises_with_prompts}/{total_exercises}")
print(f"Exercises with answers: {exercises_with_answers}/{total_exercises}")
print(f"Freeform exercises: {exercises_freeform}")
if __name__ == "__main__":
merge()
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Render book.json + ocr.json into a static HTML review page.
The HTML surfaces low-confidence OCR results in red, and shows the parsed
exercise prompts/answers next to the original image. Designed for rapid
visual diffing against the source book.
"""
import html
import json
from pathlib import Path
HERE = Path(__file__).resolve().parent
BOOK = HERE / "book.json"
OCR = HERE / "ocr.json"
OUT_HTML = HERE / "review.html"
EPUB_IMAGES = Path(HERE).parents[2] / "epub_extract" / "OEBPS"
IMAGE_REL = EPUB_IMAGES.relative_to(HERE.parent) if False else EPUB_IMAGES
def load(p: Path) -> dict:
return json.loads(p.read_text(encoding="utf-8"))
def esc(s: str) -> str:
return html.escape(s or "")
def img_tag(src: str) -> str:
full = (EPUB_IMAGES / src).resolve()
return f'<img src="file://{full}" alt="{esc(src)}" class="src"/>'
def render() -> None:
book = load(BOOK)
ocr = load(OCR) if OCR.exists() else {}
out: list = []
out.append("""<!DOCTYPE html>
<html><head><meta charset='utf-8'><title>Book review</title>
<style>
body { font-family: -apple-system, system-ui, sans-serif; margin: 2em; max-width: 1000px; color: #222; }
h1 { color: #c44; }
h2.chapter { background: #eee; padding: 0.5em; border-left: 4px solid #c44; }
h3.heading { color: #555; }
.para { margin: 0.5em 0; }
.vocab-table { background: #fafff0; padding: 0.5em; margin: 0.5em 0; border: 1px solid #bda; border-radius: 6px; }
.ocr-line { font-family: ui-monospace, monospace; font-size: 12px; }
.lowconf { color: #c44; background: #fee; }
.exercise { background: #fff8e8; padding: 0.5em; margin: 0.75em 0; border: 1px solid #cb9; border-radius: 6px; }
.prompt { font-family: ui-monospace, monospace; font-size: 13px; margin: 2px 0; }
.answer { color: #080; font-family: ui-monospace, monospace; font-size: 13px; }
img.src { max-width: 520px; border: 1px solid #ccc; margin: 4px 0; }
.kv { color: #04a; font-weight: bold; }
summary { cursor: pointer; font-weight: bold; color: #666; }
.card-pair { font-family: ui-monospace, monospace; font-size: 12px; }
.card-es { color: #04a; }
.card-en { color: #555; }
.counts { color: #888; font-size: 12px; }
</style></head><body>""")
out.append(f"<h1>{esc(book['courseName'])} — review</h1>")
out.append(f"<p>{book['totalChapters']} chapters · {book['totalExercises']} exercises · {book['totalVocabTables']} vocab tables · {book['totalVocabCards']} auto-derived cards</p>")
for ch in book["chapters"]:
part = ch.get("part")
part_str = f" (Part {part})" if part else ""
out.append(f"<h2 class='chapter'>Chapter {ch['number']}: {esc(ch['title'])}{esc(part_str)}</h2>")
for b in ch["blocks"]:
kind = b["kind"]
if kind == "heading":
level = b["level"]
out.append(f"<h{level} class='heading'>{esc(b['text'])}</h{level}>")
elif kind == "paragraph":
out.append(f"<p class='para'>{esc(b['text'])}</p>")
elif kind == "key_vocab_header":
out.append(f"<p class='kv'>★ Key Vocabulary</p>")
elif kind == "vocab_table":
src = b["sourceImage"]
conf = b["ocrConfidence"]
conf_class = "lowconf" if conf < 0.85 else ""
out.append(f"<div class='vocab-table'>")
out.append(f"<details><summary>vocab {esc(src)} · confidence {conf:.2f} · {b['cardCount']} card(s)</summary>")
out.append(img_tag(src))
out.append("<div>")
for line in b.get("ocrLines", []):
out.append(f"<div class='ocr-line {conf_class}'>{esc(line)}</div>")
out.append("</div>")
# Show derived pairs (if any). We don't have them inline in book.json,
# but we can recompute from ocrLines using the same function.
out.append("</details></div>")
elif kind == "exercise":
out.append(f"<div class='exercise'>")
out.append(f"<b>Exercise {esc(b['id'])}</b> — <i>{esc(b['instruction'])}</i>")
if b.get("extra"):
for e in b["extra"]:
out.append(f"<div class='para'>{esc(e)}</div>")
if b.get("ocrLines"):
out.append(f"<details><summary>OCR lines from image</summary>")
for line in b["ocrLines"]:
out.append(f"<div class='ocr-line'>{esc(line)}</div>")
out.append("</details>")
if b.get("prompts"):
out.append("<div><b>Parsed prompts:</b></div>")
for p in b["prompts"]:
out.append(f"<div class='prompt'>• {esc(p)}</div>")
if b.get("answerItems"):
out.append("<div><b>Answer key:</b></div>")
for a in b["answerItems"]:
label_str = f"{a['label']}. " if a.get("label") else ""
alts = ", ".join(a["alternates"])
alt_str = f" <span style='color:#999'>(also: {esc(alts)})</span>" if alts else ""
out.append(f"<div class='answer'>{esc(label_str)}{a['number']}. {esc(a['answer'])}{alt_str}</div>")
if b.get("freeform"):
out.append("<div style='color:#c44'>(Freeform — answers will vary)</div>")
for img_src in b.get("image_refs", []):
out.append(img_tag(img_src))
out.append("</div>")
out.append("</body></html>")
OUT_HTML.write_text("\n".join(out), encoding="utf-8")
print(f"Wrote {OUT_HTML}")
if __name__ == "__main__":
render()
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""Parse ans.xhtml into structured answers.json.
Output schema:
{
"answers": {
"1.1": {
"id": "1.1",
"anchor": "ch1ans1",
"chapter": 1,
"subparts": [
{"label": null, "items": [
{"number": 1, "answer": "el", "alternates": []},
{"number": 2, "answer": "el", "alternates": []},
...
]}
],
"freeform": false, # true if "Answers will vary"
"raw": "..." # raw text for fallback
},
"2.4": { # multi-part exercise
"subparts": [
{"label": "A", "items": [...]},
{"label": "B", "items": [...]},
{"label": "C", "items": [...]}
]
}
}
}
"""
import json
import re
from pathlib import Path
from bs4 import BeautifulSoup, NavigableString
ROOT = Path(__file__).resolve().parents[3] / "epub_extract" / "OEBPS"
OUT = Path(__file__).resolve().parent / "answers.json"
ANSWER_CLASSES = {"answerq", "answerq1", "answerq2", "answerqa"}
EXERCISE_ID_RE = re.compile(r"^([0-9]+)\.([0-9]+)$")
SUBPART_LABEL_RE = re.compile(r"^([A-Z])\b")
NUMBERED_ITEM_RE = re.compile(r"(?:^|\s)(\d+)\.\s+")
FREEFORM_PATTERNS = [
re.compile(r"answers? will vary", re.IGNORECASE),
re.compile(r"answer will vary", re.IGNORECASE),
]
OR_TOKEN = "{{OR}}"
def render_with_or(p) -> str:
"""Convert <p> to plain text, replacing 'OR' span markers with sentinel."""
soup = BeautifulSoup(str(p), "lxml")
# Replace <span class="small">OR</span> with sentinel
for span in soup.find_all("span"):
cls = span.get("class") or []
if "small" in cls and span.get_text(strip=True).upper() == "OR":
span.replace_with(f" {OR_TOKEN} ")
# Drop pagebreak spans
for span in soup.find_all("span", attrs={"epub:type": "pagebreak"}):
span.decompose()
# Drop emphasis but keep text
for tag in soup.find_all(["em", "i", "strong", "b"]):
tag.unwrap()
text = soup.get_text(separator=" ", strip=False)
text = re.sub(r"\s+", " ", text).strip()
return text
def split_numbered_items(text: str) -> "list[dict]":
"""Given '1. el 2. la 3. el ...' return [{'number':1,'answer':'el'}, ...]."""
# Find positions of N. tokens
matches = list(NUMBERED_ITEM_RE.finditer(text))
items = []
for i, m in enumerate(matches):
num = int(m.group(1))
start = m.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
body = text[start:end].strip().rstrip(".,;")
# Split alternates on the OR token
parts = [p.strip() for p in body.split(OR_TOKEN) if p.strip()]
if not parts:
continue
items.append({
"number": num,
"answer": parts[0],
"alternates": parts[1:],
})
return items
def parse_subpart_label(text: str) -> "tuple[str | None, str]":
"""Try to peel a leading subpart label (A, B, C) from the text.
Returns (label_or_None, remaining_text)."""
# Pattern at start: "A " or "A " (lots of whitespace from <em>A</em><tab>)
m = re.match(r"^([A-Z])\s+(?=\d)", text)
if m:
return m.group(1), text[m.end():]
return None, text
def parse_answer_paragraph(p, exercise_id: str) -> "list[dict]":
"""Convert one <p> into a list of subparts.
For p.answerq, the text typically starts with the exercise id, then items.
For p.answerqa, the text starts with a subpart label letter."""
raw = render_with_or(p)
# Strip the leading exercise id if present
raw = re.sub(rf"^{re.escape(exercise_id)}\s*", "", raw)
label, body = parse_subpart_label(raw)
# Detect freeform
freeform = any(pat.search(body) for pat in FREEFORM_PATTERNS)
if freeform:
return [{"label": label, "items": [], "freeform": True, "raw": body}]
items = split_numbered_items(body)
return [{"label": label, "items": items, "freeform": False, "raw": body}]
def main() -> None:
src = ROOT / "ans.xhtml"
soup = BeautifulSoup(src.read_text(encoding="utf-8"), "lxml")
body = soup.find("body")
answers: dict = {}
current_chapter = None
current_exercise_id: "str | None" = None
for el in body.find_all(["h3", "p"]):
classes = set(el.get("class") or [])
# Chapter boundary
if el.name == "h3" and "h3b" in classes:
text = el.get_text(strip=True)
m = re.search(r"Chapter\s+(\d+)", text)
if m:
current_chapter = int(m.group(1))
current_exercise_id = None
continue
if el.name != "p" or not (classes & ANSWER_CLASSES):
continue
# Find the exercise-id anchor (only present on p.answerq, not on continuation)
a = el.find("a", href=True)
ex_link = None
if a:
link_text = a.get_text(strip=True)
if EXERCISE_ID_RE.match(link_text):
ex_link = link_text
if ex_link:
current_exercise_id = ex_link
anchor = ""
href = a.get("href", "")
anchor_m = re.search(r"#(ch\d+ans\d+)", href + " " + (a.get("id") or ""))
anchor = anchor_m.group(1) if anchor_m else (a.get("id") or "")
# Use the anchor's `id` attr if it's the entry id (e.g. "ch1ans1")
entry_id = a.get("id") or anchor
answers[ex_link] = {
"id": ex_link,
"anchor": entry_id,
"chapter": current_chapter,
"subparts": [],
"freeform": False,
"raw": "",
}
new_subparts = parse_answer_paragraph(el, ex_link)
answers[ex_link]["subparts"].extend(new_subparts)
answers[ex_link]["raw"] = render_with_or(el)
answers[ex_link]["freeform"] = any(sp["freeform"] for sp in new_subparts)
else:
# Continuation paragraph for current exercise
if current_exercise_id and current_exercise_id in answers:
more = parse_answer_paragraph(el, current_exercise_id)
answers[current_exercise_id]["subparts"].extend(more)
if any(sp["freeform"] for sp in more):
answers[current_exercise_id]["freeform"] = True
out = {"answers": answers}
OUT.write_text(json.dumps(out, ensure_ascii=False, indent=2))
total = len(answers)
freeform = sum(1 for v in answers.values() if v["freeform"])
multipart = sum(1 for v in answers.values() if len(v["subparts"]) > 1)
total_items = sum(
len(sp["items"]) for v in answers.values() for sp in v["subparts"]
)
with_alternates = sum(
1 for v in answers.values()
for sp in v["subparts"] for it in sp["items"]
if it["alternates"]
)
print(f"Exercises with answers: {total}")
print(f" freeform: {freeform}")
print(f" multi-part (A/B/C): {multipart}")
print(f" total numbered items: {total_items}")
print(f" items with alternates:{with_alternates}")
print(f"Wrote {OUT}")
if __name__ == "__main__":
main()
@@ -0,0 +1,369 @@
#!/usr/bin/env python3
"""Parse all chapter XHTMLs + appendix into structured chapters.json.
Output schema:
{
"chapters": [
{
"id": "ch1",
"number": 1,
"title": "Nouns, Articles, and Adjectives",
"part": 1, # part 1/2/3 or null
"blocks": [ # ordered content
{"kind": "heading", "level": 3, "text": "..."},
{"kind": "paragraph", "text": "...", "hasItalic": false},
{"kind": "key_vocab_header", "title": "Los colores (The colors)"},
{"kind": "vocab_image", "src": "f0010-03.jpg"},
{
"kind": "exercise",
"id": "1.1",
"ans_anchor": "ch1ans1",
"instruction": "Write the appropriate...",
"image_refs": ["f0005-02.jpg"]
},
{"kind": "image", "src": "...", "alt": "..."}
]
}
]
}
"""
import json
import re
from pathlib import Path
from bs4 import BeautifulSoup
ROOT = Path(__file__).resolve().parents[3] / "epub_extract" / "OEBPS"
OUT = Path(__file__).resolve().parent / "chapters.json"
# Common icon images embedded in headings — ignore when collecting content images
ICON_IMAGES = {"Common01.jpg", "Common02.jpg", "Common03.jpg", "Common04.jpg", "Common05.jpg"}
EXERCISE_ID_RE = re.compile(r"Exercise\s+([0-9]+\.[0-9]+)")
ANS_REF_RE = re.compile(r"ch(\d+)ans(\d+)")
def clean_text(el) -> str:
"""Extract text preserving inline emphasis markers."""
if el is None:
return ""
# Replace <em>/<i> with markdown-ish *...*, <strong>/<b> with **...**
html = str(el)
soup = BeautifulSoup(html, "lxml")
# First: flatten nested emphasis so we don't emit overlapping markers.
# For <strong><em>X</em></strong>, drop the inner em (the bold wrapping
# already carries the emphasis visually). Same for <em><strong>...</strong></em>.
for tag in soup.find_all(["strong", "b"]):
for inner in tag.find_all(["em", "i"]):
inner.unwrap()
for tag in soup.find_all(["em", "i"]):
for inner in tag.find_all(["strong", "b"]):
inner.unwrap()
# Drop ALL inline emphasis. The source has nested/sibling em/strong
# patterns that CommonMark can't reliably parse, causing markers to leak
# into the UI. Plain text renders cleanly everywhere.
for tag in soup.find_all(["em", "i", "strong", "b"]):
tag.unwrap()
# Drop pagebreak spans
for tag in soup.find_all("span", attrs={"epub:type": "pagebreak"}):
tag.decompose()
# Replace <br/> with newline
for br in soup.find_all("br"):
br.replace_with("\n")
# Use a separator so adjacent inline tags don't concatenate without spaces
# (e.g. "<strong><em>Ir</em></strong> and" would otherwise become "Irand").
text = soup.get_text(separator=" ", strip=False)
# Collapse runs of whitespace first.
text = re.sub(r"\s+", " ", text).strip()
# Strip any stray asterisks that sneak through (e.g. author's literal *).
text = text.replace("*", "")
# De-space punctuation
text = re.sub(r"\s+([,.;:!?])", r"\1", text)
# Tighten brackets that picked up separator-spaces: "( foo )" -> "(foo)"
text = re.sub(r"([(\[])\s+", r"\1", text)
text = re.sub(r"\s+([)\]])", r"\1", text)
# Collapse any double-spaces
text = re.sub(r" +", " ", text).strip()
return text
def is_exercise_header(h) -> bool:
"""Heading with an <a href='ans.xhtml#...'>Exercise N.N</a> link.
Chapters 1-16 use h3.h3k; chapters 17+ use h4.h4."""
if h.name not in ("h3", "h4"):
return False
a = h.find("a", href=True)
if a and "ans.xhtml" in a["href"]:
return True
return False
def is_key_vocab_header(h) -> bool:
"""Heading with 'Key Vocabulary' text (no anchor link to answers)."""
if h.name not in ("h3", "h4"):
return False
text = h.get_text(strip=True)
if "Key Vocabulary" in text and not h.find("a", href=lambda v: v and "ans.xhtml" in v):
return True
return False
def extract_image_srcs(parent) -> list:
"""Return list of image src attributes, skipping icon images."""
srcs = []
for img in parent.find_all("img"):
src = img.get("src", "")
if not src or Path(src).name in ICON_IMAGES:
continue
srcs.append(src)
return srcs
def parse_chapter(path: Path) -> "dict | None":
"""Parse one chapter file into structured blocks."""
html = path.read_text(encoding="utf-8")
soup = BeautifulSoup(html, "lxml")
body = soup.find("body")
if body is None:
return None
# Chapter number + title
number = None
title = ""
h2s = body.find_all("h2")
for h2 in h2s:
classes = h2.get("class") or []
# Use a separator so consecutive inline tags don't concatenate
# (e.g. "<strong><em>Ir</em></strong> and the Future" → "Ir and the Future")
text_with_sep = re.sub(r"\s+", " ", h2.get_text(" ", strip=True))
# Strip spaces that were inserted before punctuation
text_with_sep = re.sub(r"\s+([,.;:!?])", r"\1", text_with_sep).strip()
if "h2c" in classes and text_with_sep.isdigit():
number = int(text_with_sep)
# Chapters 116 use h2c1; chapters 17+ use h2-c
elif ("h2c1" in classes or "h2-c" in classes) and not title:
title = text_with_sep
if number is None:
# Try id on chapter header (ch1 → 1)
for h2 in h2s:
id_ = h2.get("id", "")
m = re.match(r"ch(\d+)", id_)
if m:
number = int(m.group(1))
break
chapter_id = path.stem # ch1, ch2, ...
# Walk section content in document order
section = body.find("section") or body
blocks: list = []
pending_instruction = None # holds italic paragraph following an exercise header
for el in section.descendants:
if el.name is None:
continue
classes = el.get("class") or []
# Skip nested tags already captured via parent processing
# We operate only on direct h2/h3/h4/h5/p elements
if el.name not in ("h2", "h3", "h4", "h5", "p"):
continue
# Exercise header detection (h3 in ch1-16, h4 in ch17+)
if is_exercise_header(el):
a = el.find("a", href=True)
href = a["href"] if a else ""
m = EXERCISE_ID_RE.search(el.get_text())
ex_id = m.group(1) if m else ""
anchor_m = ANS_REF_RE.search(href)
ans_anchor = anchor_m.group(0) if anchor_m else ""
blocks.append({
"kind": "exercise",
"id": ex_id,
"ans_anchor": ans_anchor,
"instruction": "",
"image_refs": [],
"prompts": []
})
pending_instruction = blocks[-1]
continue
# Key Vocabulary header
if is_key_vocab_header(el):
blocks.append({"kind": "key_vocab_header", "title": "Key Vocabulary"})
pending_instruction = None
continue
# Other headings
if el.name in ("h2", "h3", "h4", "h5"):
if el.name == "h2":
# Skip the chapter-number/chapter-title h2s we already captured
continue
txt = clean_text(el)
if txt:
blocks.append({
"kind": "heading",
"level": int(el.name[1]),
"text": txt,
})
pending_instruction = None
continue
# Paragraphs
if el.name == "p":
imgs = extract_image_srcs(el)
text = clean_text(el)
p_classes = set(classes)
# Skip pure blank-line class ("nump" = underscore lines under number prompts)
if p_classes & {"nump", "numpa"} and not text:
continue
# Exercise prompt: <p class="number">1. Prompt text</p>
# Also number1, number2 (continuation numbering), numbera, numbert
if pending_instruction is not None and p_classes & {"number", "number1", "number2", "numbera", "numbert"}:
if text:
pending_instruction["prompts"].append(text)
continue
# Image container for a pending exercise
if pending_instruction is not None and imgs and not text:
pending_instruction["image_refs"].extend(imgs)
continue
# Instruction line right after the exercise header
if pending_instruction is not None and text and not imgs and not pending_instruction["instruction"]:
pending_instruction["instruction"] = text
continue
# While in pending-exercise state, extra text paragraphs are word
# banks / context ("from the following list:" etc) — keep pending alive.
if pending_instruction is not None and text and not imgs:
pending_instruction.setdefault("extra", []).append(text)
continue
# Paragraphs that contain an image belong to vocab/key-vocab callouts
if imgs and not text:
for src in imgs:
blocks.append({"kind": "vocab_image", "src": src})
continue
# Mixed paragraph: image with caption
if imgs and text:
for src in imgs:
blocks.append({"kind": "vocab_image", "src": src})
blocks.append({"kind": "paragraph", "text": text})
continue
# Plain paragraph — outside any exercise
if text:
blocks.append({"kind": "paragraph", "text": text})
return {
"id": chapter_id,
"number": number,
"title": title,
"blocks": blocks,
}
def assign_parts(chapters: list, part_files: "dict[int, list[int]]") -> None:
"""Annotate chapters with part number based on TOC membership."""
for part_num, chapter_nums in part_files.items():
for ch in chapters:
if ch["number"] in chapter_nums:
ch["part"] = part_num
for ch in chapters:
ch.setdefault("part", None)
def read_part_memberships() -> "dict[int, list[int]]":
"""Derive part→chapter grouping from the OPF spine order."""
opf = next(ROOT.glob("*.opf"), None)
if opf is None:
return {}
soup = BeautifulSoup(opf.read_text(encoding="utf-8"), "xml")
memberships: dict = {}
current_part: "int | None" = None
for item in soup.find_all("item"):
href = item.get("href", "")
m_part = re.match(r"part(\d+)\.xhtml", href)
m_ch = re.match(r"ch(\d+)\.xhtml", href)
if m_part:
current_part = int(m_part.group(1))
memberships.setdefault(current_part, [])
elif m_ch and current_part is not None:
memberships[current_part].append(int(m_ch.group(1)))
# Manifest order tends to match spine order for this book; verify via spine just in case
spine = soup.find("spine")
if spine is not None:
order = []
for ref in spine.find_all("itemref"):
idref = ref.get("idref")
item = soup.find("item", attrs={"id": idref})
if item is not None:
order.append(item.get("href", ""))
# Rebuild from spine order
memberships = {}
current_part = None
for href in order:
m_part = re.match(r"part(\d+)\.xhtml", href)
m_ch = re.match(r"ch(\d+)\.xhtml", href)
if m_part:
current_part = int(m_part.group(1))
memberships.setdefault(current_part, [])
elif m_ch and current_part is not None:
memberships[current_part].append(int(m_ch.group(1)))
return memberships
def main() -> None:
chapter_files = sorted(
ROOT.glob("ch*.xhtml"),
key=lambda p: int(re.match(r"ch(\d+)", p.stem).group(1))
)
chapters = []
for path in chapter_files:
ch = parse_chapter(path)
if ch:
chapters.append(ch)
part_memberships = read_part_memberships()
assign_parts(chapters, part_memberships)
out = {
"chapters": chapters,
"part_memberships": part_memberships,
}
OUT.write_text(json.dumps(out, ensure_ascii=False, indent=2))
# Summary
ex_total = sum(1 for ch in chapters for b in ch["blocks"] if b["kind"] == "exercise")
ex_with_prompts = sum(
1 for ch in chapters for b in ch["blocks"]
if b["kind"] == "exercise" and b["prompts"]
)
ex_with_images = sum(
1 for ch in chapters for b in ch["blocks"]
if b["kind"] == "exercise" and b["image_refs"]
)
ex_empty = sum(
1 for ch in chapters for b in ch["blocks"]
if b["kind"] == "exercise" and not b["prompts"] and not b["image_refs"]
)
para_total = sum(1 for ch in chapters for b in ch["blocks"] if b["kind"] == "paragraph")
vocab_img_total = sum(1 for ch in chapters for b in ch["blocks"] if b["kind"] == "vocab_image")
print(f"Chapters: {len(chapters)}")
print(f"Exercises total: {ex_total}")
print(f" with text prompts: {ex_with_prompts}")
print(f" with image prompts: {ex_with_images}")
print(f" empty: {ex_empty}")
print(f"Paragraphs: {para_total}")
print(f"Vocab images: {vocab_img_total}")
print(f"Parts: {part_memberships}")
print(f"Wrote {OUT}")
if __name__ == "__main__":
main()
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Extract clean text from the PDF source and map each PDF page to the
book's printed page number.
Output: pdf_text.json
{
"pdfPageCount": 806,
"bookPages": {
"3": { "text": "...", "pdfIndex": 29 },
"4": { ... },
...
},
"unmapped": [list of pdfIndex values with no detectable book page number]
}
"""
import json
import re
from pathlib import Path
import pypdf
HERE = Path(__file__).resolve().parent
PDF = next(
Path(__file__).resolve().parents[3].glob("Complete Spanish Step-By-Step*.pdf"),
None,
)
OUT = HERE / "pdf_text.json"
ROMAN_RE = re.compile(r"^[ivxlcdmIVXLCDM]+$")
# Match a page number on its own line at top/bottom of the page.
# The book uses Arabic numerals for main chapters (e.g., "3") and Roman for front matter.
PAGE_NUM_LINE_RE = re.compile(r"^\s*(\d{1,4})\s*$", re.MULTILINE)
def detect_book_page(text: str) -> "int | None":
"""Find the printed page number from standalone page-number lines at the
top or bottom of a page."""
lines = [l.strip() for l in text.splitlines() if l.strip()]
# Check first 2 lines and last 2 lines
for candidate in lines[:2] + lines[-2:]:
m = re.match(r"^(\d{1,4})$", candidate)
if m:
return int(m.group(1))
return None
def main() -> None:
if PDF is None:
print("No PDF found in project root")
return
print(f"Reading {PDF.name}")
reader = pypdf.PdfReader(str(PDF))
pages = reader.pages
print(f"PDF has {len(pages)} pages")
by_book_page: dict = {}
unmapped: list = []
last_seen: "int | None" = None
missed_count = 0
for i, page in enumerate(pages):
text = page.extract_text() or ""
book_page = detect_book_page(text)
if book_page is None:
# Carry forward sequence: if we saw page N last, assume N+1.
if last_seen is not None:
book_page = last_seen + 1
missed_count += 1
else:
unmapped.append(i)
continue
last_seen = book_page
# Strip the detected page number from text to clean the output
cleaned = re.sub(r"(?m)^\s*\d{1,4}\s*$", "", text).strip()
by_book_page[str(book_page)] = {
"text": cleaned,
"pdfIndex": i,
}
out = {
"pdfPageCount": len(pages),
"bookPages": by_book_page,
"unmapped": unmapped,
"inferredPages": missed_count,
}
OUT.write_text(json.dumps(out, ensure_ascii=False))
print(f"Mapped {len(by_book_page)} book pages; {missed_count} inferred; {len(unmapped)} unmapped")
print(f"Wrote {OUT}")
if __name__ == "__main__":
main()
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Apply high-confidence auto-fixes from vocab_validation.json to vocab_cards.json.
Auto-fix rules (conservative):
1. If a flagged word has exactly one suggestion AND that suggestion differs by
<= 2 characters AND has the same starting letter (high-confidence character swap).
2. If a card is detected as reversed (Spanish on EN side, English on ES side),
swap front/back.
Cards that aren't auto-fixable end up in manual_review.json.
"""
import json
import re
import unicodedata
from pathlib import Path
HERE = Path(__file__).resolve().parent
VOCAB = HERE / "vocab_cards.json"
VALIDATION = HERE / "vocab_validation.json"
OUT_VOCAB = HERE / "vocab_cards.json"
OUT_REVIEW = HERE / "manual_review.json"
OUT_QUARANTINE = HERE / "quarantined_cards.json"
def _strip_accents(s: str) -> str:
return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn")
def _levenshtein(a: str, b: str) -> int:
if a == b: return 0
if not a: return len(b)
if not b: return len(a)
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
curr = [i]
for j, cb in enumerate(b, 1):
cost = 0 if ca == cb else 1
curr.append(min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost))
prev = curr
return prev[-1]
SPANISH_ACCENT_RE = re.compile(r"[áéíóúñüÁÉÍÓÚÑÜ¿¡]")
SPANISH_ARTICLES = {"el", "la", "los", "las", "un", "una", "unos", "unas"}
ENGLISH_STARTERS = {"the", "a", "an", "to", "my", "his", "her", "our", "their"}
HABER_FORMS = {"he", "has", "ha", "hemos", "habéis", "han"}
def language_score(s: str) -> "tuple[int, int]":
"""Return (es_score, en_score) for a string."""
es = 0
en = 0
if SPANISH_ACCENT_RE.search(s):
es += 3
words = s.lower().split()
if not words:
return (es, en)
first = words[0].strip(",.;:")
second = words[1].strip(",.;:") if len(words) > 1 else ""
# Spanish present-perfect ("he tenido", "Ha andado") starts with a haber
# form followed by an -ado/-ido past participle. Recognise this pattern
# before the bare-pronoun check so "he" isn't mistaken for English "he".
if first in HABER_FORMS and (
second.endswith(("ado", "ido", "to", "cho", "sto", "esto"))
):
es += 3
elif first in SPANISH_ARTICLES:
es += 2
elif first in ENGLISH_STARTERS:
en += 2
# Spanish-likely endings on later words
for w in words:
w = w.strip(",.;:")
if not w: continue
if w.endswith(("ción", "sión", "dad", "tud")):
es += 1
if w.endswith(("ing", "tion", "ness", "ment", "able", "ly")):
en += 1
return (es, en)
def is_reversed(front: str, back: str) -> bool:
"""True when front looks like English and back looks like Spanish (i.e. swapped)."""
fes, fen = language_score(front)
bes, ben = language_score(back)
# Front English-leaning AND back Spanish-leaning
return fen > fes and bes > ben
def best_replacement(word: str, suggestions: list) -> "str | None":
"""Pick the one safe correction, or None to leave it alone."""
if not suggestions:
return None
# Prefer suggestions that share the same first letter
same_initial = [s for s in suggestions if s and word and s[0].lower() == word[0].lower()]
candidates = same_initial or suggestions
# Single best: short edit distance
best = None
best_d = 99
for s in candidates:
d = _levenshtein(word.lower(), s.lower())
# Don't apply if the "fix" changes too much
if d == 0:
continue
if d > 2:
continue
if d < best_d:
best = s
best_d = d
return best
def side_language_match(text: str, expected_side: str) -> bool:
"""Return True when `text` looks like the expected language (es/en).
Guards against applying Spanish spell-fix to English words on a mis-paired card.
"""
es, en = language_score(text)
if expected_side == "es":
return es > en # require clear Spanish signal
if expected_side == "en":
return en >= es # allow equal when text has no strong signal (common for English)
return False
def apply_word_fixes(text: str, bad_words: list, expected_side: str) -> "tuple[str, list]":
"""Apply word-level corrections inside a string. Skips fixes entirely when
the side's actual language doesn't match the dictionary used, to avoid
corrupting mis-paired cards."""
if not side_language_match(text, expected_side):
return (text, [])
new_text = text
applied = []
for bw in bad_words:
word = bw["word"]
sugg = bw["suggestions"]
replacement = best_replacement(word, sugg)
if replacement is None:
continue
# Match standalone word including the (possibly-omitted) trailing period:
# `Uds` in the text should be replaced with `Uds.` even when adjacent to `.`.
escaped = re.escape(word)
# Allow an optional existing period that we'd otherwise duplicate.
pattern = re.compile(rf"(?<![A-Za-zÁ-ú]){escaped}\.?(?![A-Za-zÁ-ú])")
if pattern.search(new_text):
new_text = pattern.sub(replacement, new_text, count=1)
applied.append({"from": word, "to": replacement})
return (new_text, applied)
def main() -> None:
vocab_data = json.loads(VOCAB.read_text(encoding="utf-8"))
val_data = json.loads(VALIDATION.read_text(encoding="utf-8"))
# Index validation by (chapter, front, back, sourceImage) for lookup
val_index: dict = {}
for f in val_data["flags"]:
key = (f["chapter"], f["front"], f["back"], f["sourceImage"])
val_index[key] = f
# Walk the cards in place
auto_fixed_word = 0
auto_swapped = 0
quarantined = 0
manual_review_cards = []
quarantined_cards = []
for ch in vocab_data["chapters"]:
kept_cards = []
for card in ch["cards"]:
key = (ch["chapter"], card["front"], card["back"], card.get("sourceImage", ""))
flag = val_index.get(key)
# 1) Reversal swap (apply even when not flagged)
if is_reversed(card["front"], card["back"]):
card["front"], card["back"] = card["back"], card["front"]
auto_swapped += 1
# Re-key for any further validation lookup (no-op here)
if flag is None:
kept_cards.append(card)
continue
# Quarantine only clear mis-pairs: both sides EXPLICITLY the wrong
# language (both Spanish or both English). "unknown" sides stay —
# the bounding-box pipeline already handled orientation correctly
# and many valid pairs lack the article/accent markers we classify on.
fes, fen = language_score(card["front"])
bes, ben = language_score(card["back"])
front_lang = "es" if fes > fen else ("en" if fen > fes else "unknown")
back_lang = "es" if bes > ben else ("en" if ben > bes else "unknown")
bothSameLang = (front_lang == "es" and back_lang == "es") or (front_lang == "en" and back_lang == "en")
reversed_pair = front_lang == "en" and back_lang == "es"
if bothSameLang or reversed_pair:
quarantined_cards.append({
"chapter": ch["chapter"],
"front": card["front"],
"back": card["back"],
"sourceImage": card.get("sourceImage", ""),
"reason": f"language-mismatch front={front_lang} back={back_lang}",
})
quarantined += 1
continue
# 2) Word-level fixes (language-aware)
new_front, applied_front = apply_word_fixes(card["front"], flag["badFront"], "es")
new_back, applied_back = apply_word_fixes(card["back"], flag["badBack"], "en")
card["front"] = new_front
card["back"] = new_back
auto_fixed_word += len(applied_front) + len(applied_back)
# If after auto-fix there are STILL flagged words with no
# confident replacement, flag for manual review.
unresolved_front = [
bw for bw in flag["badFront"]
if not any(a["from"] == bw["word"] for a in applied_front)
and best_replacement(bw["word"], bw["suggestions"]) is None
]
unresolved_back = [
bw for bw in flag["badBack"]
if not any(a["from"] == bw["word"] for a in applied_back)
and best_replacement(bw["word"], bw["suggestions"]) is None
]
if unresolved_front or unresolved_back:
manual_review_cards.append({
"chapter": ch["chapter"],
"front": card["front"],
"back": card["back"],
"sourceImage": card.get("sourceImage", ""),
"unresolvedFront": unresolved_front,
"unresolvedBack": unresolved_back,
})
kept_cards.append(card)
ch["cards"] = kept_cards
OUT_VOCAB.write_text(json.dumps(vocab_data, ensure_ascii=False, indent=2))
OUT_REVIEW.write_text(json.dumps({
"totalManualReview": len(manual_review_cards),
"cards": manual_review_cards,
}, ensure_ascii=False, indent=2))
OUT_QUARANTINE.write_text(json.dumps({
"totalQuarantined": len(quarantined_cards),
"cards": quarantined_cards,
}, ensure_ascii=False, indent=2))
total_cards = sum(len(c["cards"]) for c in vocab_data["chapters"])
print(f"Active cards (after quarantine): {total_cards}")
print(f"Auto-swapped (reversed): {auto_swapped}")
print(f"Auto-fixed words: {auto_fixed_word}")
print(f"Quarantined (mis-paired): {quarantined}")
print(f"Cards needing manual review: {len(manual_review_cards)}")
print(f"Wrote {OUT_VOCAB}")
print(f"Wrote {OUT_REVIEW}")
print(f"Wrote {OUT_QUARANTINE}")
if __name__ == "__main__":
main()
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Merge repaired_cards.json into vocab_cards.json.
Rules:
1. New pairs are added to their chapter's deck if they don't duplicate an existing pair.
2. Duplicate detection uses normalize(front)+normalize(back).
3. Pairs whose back side starts with a Spanish-article or front side starts
with an English article are dropped (pairer got orientation wrong).
4. Emits integrate_report.json with counts.
"""
import json
import re
import unicodedata
from pathlib import Path
HERE = Path(__file__).resolve().parent
VOCAB = HERE / "vocab_cards.json"
REPAIRED = HERE / "repaired_cards.json"
QUARANTINED = HERE / "quarantined_cards.json"
OUT = HERE / "vocab_cards.json"
REPORT = HERE / "integrate_report.json"
def _strip_accents(s: str) -> str:
return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn")
def norm(s: str) -> str:
return _strip_accents(s.lower()).strip()
SPANISH_ACCENT_RE = re.compile(r"[áéíóúñüÁÉÍÓÚÑÜ¿¡]")
SPANISH_ARTICLES = {"el", "la", "los", "las", "un", "una", "unos", "unas"}
ENGLISH_STARTERS = {"the", "a", "an", "to", "my", "his", "her", "our", "their"}
def looks_swapped(front: str, back: str) -> bool:
"""True if front looks English and back looks Spanish (pair should be swapped)."""
fl = front.lower().split()
bl = back.lower().split()
if not fl or not bl:
return False
f_first = fl[0].strip(",.;:")
b_first = bl[0].strip(",.;:")
front_is_en = f_first in ENGLISH_STARTERS
back_is_es = (
SPANISH_ACCENT_RE.search(back) is not None
or b_first in SPANISH_ARTICLES
)
return front_is_en and back_is_es
def looks_good(pair: dict) -> bool:
"""Basic sanity filter on a repaired pair before it enters the deck."""
es = pair["es"].strip()
en = pair["en"].strip()
if not es or not en: return False
if len(es) < 2 or len(en) < 2: return False
# Drop if both sides obviously same language (neither has clear orientation)
es_has_accent = SPANISH_ACCENT_RE.search(es) is not None
en_has_accent = SPANISH_ACCENT_RE.search(en) is not None
if en_has_accent and not es_has_accent:
# The "en" side has accents — likely swapped
return False
return True
def main() -> None:
vocab = json.loads(VOCAB.read_text(encoding="utf-8"))
repaired = json.loads(REPAIRED.read_text(encoding="utf-8"))
quarantined = json.loads(QUARANTINED.read_text(encoding="utf-8"))
# Map image → chapter (from the quarantine list — all images here belong to the
# chapter they were quarantined from).
image_chapter: dict = {}
for c in quarantined["cards"]:
image_chapter[c["sourceImage"]] = c["chapter"]
# Build existing key set
existing_keys = set()
chapter_map: dict = {c["chapter"]: c for c in vocab["chapters"]}
for c in vocab["chapters"]:
for card in c["cards"]:
existing_keys.add((c["chapter"], norm(card["front"]), norm(card["back"])))
added_per_image: dict = {}
dropped_swapped = 0
dropped_sanity = 0
dropped_dup = 0
for image_name, data in repaired["byImage"].items():
ch_num = image_chapter.get(image_name)
if ch_num is None:
# Image not in quarantine list (shouldn't happen, but bail)
continue
deck = chapter_map.setdefault(ch_num, {"chapter": ch_num, "cards": []})
added = 0
for p in data.get("pairs", []):
es = p["es"].strip()
en = p["en"].strip()
if looks_swapped(es, en):
es, en = en, es
pair = {"es": es, "en": en}
if not looks_good(pair):
dropped_sanity += 1
continue
key = (ch_num, norm(pair["es"]), norm(pair["en"]))
if key in existing_keys:
dropped_dup += 1
continue
existing_keys.add(key)
card = {
"front": pair["es"],
"back": pair["en"],
"chapter": ch_num,
"chapterTitle": "",
"section": "",
"sourceImage": image_name,
}
deck["cards"].append(card)
added += 1
if added:
added_per_image[image_name] = added
# If any new chapter was created, ensure ordered insertion
vocab["chapters"] = sorted(chapter_map.values(), key=lambda c: c["chapter"])
OUT.write_text(json.dumps(vocab, ensure_ascii=False, indent=2))
total_added = sum(added_per_image.values())
report = {
"totalRepairedInput": repaired["totalPairs"],
"added": total_added,
"dropped_duplicate": dropped_dup,
"dropped_sanity": dropped_sanity,
"addedPerImage": added_per_image,
}
REPORT.write_text(json.dumps(report, ensure_ascii=False, indent=2))
print(f"Repaired pairs in: {repaired['totalPairs']}")
print(f"Added to deck: {total_added}")
print(f"Dropped as duplicate: {dropped_dup}")
print(f"Dropped as swapped/bad: {dropped_sanity}")
print(f"Wrote {OUT}")
if __name__ == "__main__":
main()
@@ -0,0 +1,471 @@
#!/usr/bin/env python3
"""Second-pass extractor: use PDF OCR (from ocr_pdf.swift) as a supplementary
source of clean text, then re-build book.json with PDF-derived content where it
improves on the EPUB's image-based extraction.
Inputs:
chapters.json — EPUB structural extraction (narrative text + exercise prompts + image refs)
answers.json — EPUB answer key
ocr.json — EPUB image OCR (first pass)
pdf_ocr.json — PDF page-level OCR (this pass, higher DPI + cleaner)
Outputs:
book.json — merged book used by the app
vocab_cards.json — derived vocabulary flashcards
"""
import json
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
from build_book import ( # reuse the helpers defined in build_book.py
COURSE_NAME,
build_vocab_cards_for_block,
clean_instruction,
classify_line,
load,
)
CHAPTERS_JSON = HERE / "chapters.json"
ANSWERS_JSON = HERE / "answers.json"
OCR_JSON = HERE / "ocr.json"
PDF_OCR_JSON = HERE / "pdf_ocr.json"
PAIRED_VOCAB_JSON = HERE / "paired_vocab.json" # bounding-box pairs (fallback)
PAIRED_VOCAB_LLM_JSON = HERE / "paired_vocab_llm.json" # LLM vision pairs (preferred)
OUT_BOOK = HERE / "book.json"
OUT_VOCAB = HERE / "vocab_cards.json"
IMAGE_NAME_RE = re.compile(r"^f(\d{4})-(\d{2})\.jpg$")
def extract_book_page(image_src: str) -> "int | None":
m = IMAGE_NAME_RE.match(image_src)
return int(m.group(1)) if m else None
def build_pdf_page_index(pdf_ocr: dict) -> "dict[int, dict]":
"""Map bookPage → {lines, confidence, pdfIndex}.
Strategy: use chapter-start alignments as anchors. For each chapter N,
anchor[N] = (pdf_idx_where_chapter_starts, book_page_where_chapter_starts).
Between anchors we interpolate page-by-page (pages run sequentially within
a chapter in this textbook's layout).
"""
pages: "dict[int, dict]" = {}
sorted_keys = sorted(pdf_ocr.keys(), key=lambda k: int(k))
# --- Detect chapter starts in the PDF OCR ---
pdf_ch_start: "dict[int, int]" = {}
for k in sorted_keys:
entry = pdf_ocr[k]
lines = entry.get("lines", [])
if len(lines) < 2:
continue
first = lines[0].strip()
second = lines[1].strip()
if first.isdigit() and 1 <= int(first) <= 30 and len(second) > 5 and second[0:1].isupper():
ch = int(first)
if ch not in pdf_ch_start:
pdf_ch_start[ch] = int(k)
# --- Load EPUB's authoritative book-page starts ---
import re as _re
from bs4 import BeautifulSoup as _BS
epub_root = HERE.parents[2] / "epub_extract" / "OEBPS"
book_ch_start: "dict[int, int]" = {}
for ch in sorted(pdf_ch_start.keys()):
p = epub_root / f"ch{ch}.xhtml"
if not p.exists():
continue
soup = _BS(p.read_text(encoding="utf-8"), "lxml")
for span in soup.find_all(True):
id_ = span.get("id", "") or ""
m = _re.match(r"page_(\d+)$", id_)
if m:
book_ch_start[ch] = int(m.group(1))
break
# Build per-chapter (pdf_anchor, book_anchor, next_pdf_anchor) intervals
anchors = [] # list of (ch, pdf_start, book_start)
for ch in sorted(pdf_ch_start.keys()):
if ch in book_ch_start:
anchors.append((ch, pdf_ch_start[ch], book_ch_start[ch]))
for i, (ch, pdf_s, book_s) in enumerate(anchors):
next_pdf = anchors[i + 1][1] if i + 1 < len(anchors) else pdf_s + 50
# Interpolate book page for each pdf index in [pdf_s, next_pdf)
for pdf_idx in range(pdf_s, next_pdf):
book_page = book_s + (pdf_idx - pdf_s)
entry = pdf_ocr.get(str(pdf_idx))
if entry is None:
continue
if book_page in pages:
continue
pages[book_page] = {
"lines": entry["lines"],
"confidence": entry.get("confidence", 0),
"pdfIndex": pdf_idx,
}
return pages
def merge_ocr(epub_lines: list, pdf_lines: list) -> list:
"""EPUB per-image OCR is our primary (targeted, no prose bleed). PDF
page-level OCR is only used when EPUB is missing. Per-line accent repair
is handled separately via `repair_accents_from_pdf`.
"""
if epub_lines:
return epub_lines
return pdf_lines
import unicodedata as _u
def _strip_accents(s: str) -> str:
return "".join(c for c in _u.normalize("NFD", s) if _u.category(c) != "Mn")
def _levenshtein(a: str, b: str) -> int:
if a == b: return 0
if not a: return len(b)
if not b: return len(a)
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
curr = [i]
for j, cb in enumerate(b, 1):
cost = 0 if ca == cb else 1
curr.append(min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost))
prev = curr
return prev[-1]
def repair_accents_from_pdf(epub_lines: list, pdf_page_lines: list) -> "tuple[list, int]":
"""For each EPUB OCR line, find a near-match in the PDF page OCR and
prefer the PDF version. Repairs include:
1. exact accent/case differences (e.g. 'iglesia' vs 'Iglesia')
2. single-character OCR errors (e.g. 'the hrother' -> 'the brother')
3. two-character OCR errors when the target is long enough
"""
if not epub_lines or not pdf_page_lines:
return (epub_lines, 0)
# Pre-normalize PDF lines for matching
pdf_cleaned = [p.strip() for p in pdf_page_lines if p.strip()]
pdf_by_stripped: dict = {}
for p in pdf_cleaned:
key = _strip_accents(p.lower())
pdf_by_stripped.setdefault(key, p)
out: list = []
repairs = 0
for e in epub_lines:
e_stripped = e.strip()
e_key = _strip_accents(e_stripped.lower())
# Pass 1: exact accent-only difference
if e_key and e_key in pdf_by_stripped and pdf_by_stripped[e_key] != e_stripped:
out.append(pdf_by_stripped[e_key])
repairs += 1
continue
# Pass 2: fuzzy — find best PDF line within edit distance 1 or 2
if len(e_key) >= 4:
max_distance = 1 if len(e_key) < 10 else 2
best_match = None
best_d = max_distance + 1
for p in pdf_cleaned:
p_key = _strip_accents(p.lower())
# Only match lines of similar length
if abs(len(p_key) - len(e_key)) > max_distance:
continue
d = _levenshtein(e_key, p_key)
if d < best_d:
best_d = d
best_match = p
if d == 0:
break
if best_match and best_match != e_stripped and best_d <= max_distance:
out.append(best_match)
repairs += 1
continue
out.append(e)
return (out, repairs)
def vocab_lines_from_pdf_page(
pdf_page_entry: dict,
epub_narrative_lines: set
) -> list:
"""Extract likely vocab-table lines from a PDF page's OCR by filtering out
narrative-looking lines (long sentences) and already-known EPUB content."""
lines = pdf_page_entry.get("lines", [])
out: list = []
for raw in lines:
line = raw.strip()
if not line:
continue
# Skip lines that look like body prose (too long)
if len(line) > 80:
continue
# Skip narrative we already captured in the EPUB
if line in epub_narrative_lines:
continue
# Skip page-number-only lines
if re.fullmatch(r"\d{1,4}", line):
continue
# Skip standalone chapter headers (e.g. "Nouns, Articles, and Adjectives")
out.append(line)
return out
def main() -> None:
chapters_data = load(CHAPTERS_JSON)
answers = load(ANSWERS_JSON)["answers"]
epub_ocr = load(OCR_JSON)
pdf_ocr_raw = load(PDF_OCR_JSON) if PDF_OCR_JSON.exists() else {}
pdf_pages = build_pdf_page_index(pdf_ocr_raw) if pdf_ocr_raw else {}
paired_vocab = load(PAIRED_VOCAB_JSON) if PAIRED_VOCAB_JSON.exists() else {}
paired_llm = load(PAIRED_VOCAB_LLM_JSON) if PAIRED_VOCAB_LLM_JSON.exists() else {}
print(f"Mapped {len(pdf_pages)} PDF pages to book page numbers")
print(f"Loaded bounding-box pairs for {len(paired_vocab)} vocab images")
print(f"Loaded LLM-vision pairs for {len(paired_llm)} vocab images")
# Build a global set of EPUB narrative lines (for subtraction when pulling vocab)
narrative_set = set()
for ch in chapters_data["chapters"]:
for b in ch["blocks"]:
if b["kind"] == "paragraph" and b.get("text"):
narrative_set.add(b["text"].strip())
book_chapters = []
all_vocab_cards = []
pdf_hits = 0
pdf_misses = 0
merged_pages = 0
for ch in chapters_data["chapters"]:
out_blocks = []
current_section_title = ch["title"]
for bi, block in enumerate(ch["blocks"]):
k = block["kind"]
if k == "heading":
current_section_title = block["text"]
out_blocks.append(block)
continue
if k == "paragraph":
out_blocks.append(block)
continue
if k == "key_vocab_header":
out_blocks.append(block)
continue
if k == "vocab_image":
src = block["src"]
epub_entry = epub_ocr.get(src)
epub_lines = epub_entry.get("lines", []) if epub_entry else []
epub_conf = epub_entry.get("confidence", 0.0) if epub_entry else 0.0
book_page = extract_book_page(src)
pdf_entry = pdf_pages.get(book_page) if book_page else None
pdf_lines = pdf_entry["lines"] if pdf_entry else []
# Primary: EPUB per-image OCR. Supplementary: PDF page OCR
# used only for accent/diacritic repair where keys match.
if pdf_lines:
pdf_hits += 1
else:
pdf_misses += 1
repaired_lines, repairs = repair_accents_from_pdf(epub_lines, pdf_lines)
merged_lines = repaired_lines if repaired_lines else pdf_lines
merged_conf = max(epub_conf, pdf_entry.get("confidence", 0) if pdf_entry else 0.0)
if repairs > 0:
merged_pages += 1
# Source priority:
# 1) LLM-vision pairs (paired_vocab_llm.json) — semantic
# classification (pair_table / reference_only / hybrid)
# with correct orientation.
# 2) Bounding-box pairs (paired_vocab.json) — Vision OCR
# with X-gap row splitting.
# 3) Block-alternation heuristic — flat OCR fallback.
llm_entry = paired_llm.get(src, {}) if isinstance(paired_llm.get(src), dict) else {}
llm_kind = llm_entry.get("kind")
llm_pairs = llm_entry.get("pairs", []) if llm_entry else []
bbox = paired_vocab.get(src, {})
bbox_pairs = bbox.get("pairs", []) if isinstance(bbox, dict) else []
heuristic = build_vocab_cards_for_block(
{"src": src},
{"lines": merged_lines, "confidence": merged_conf},
ch, current_section_title, bi
)
# Choose pair source. For reference_only (Spanish-only tables)
# we deliberately produce no cards — the UI will fall back to
# rendering the flat OCR lines as a reference list. Same for
# hybrid images where the LLM determined no genuine pair rows
# exist (e.g. estar conjugations with English glosses on the
# header row only).
if llm_kind == "reference_only" or (llm_kind == "hybrid" and not llm_pairs):
cards_for_block = []
pair_source = "llm-no-pairs"
elif llm_pairs:
cards_for_block = [
{"front": p["es"], "back": p["en"]}
for p in llm_pairs
if p.get("es") and p.get("en")
]
for c in cards_for_block:
all_vocab_cards.append({
"front": c["front"], "back": c["back"],
"chapter": ch["number"],
"chapterTitle": ch["title"],
"section": current_section_title,
"sourceImage": src,
})
pair_source = "llm-" + (llm_kind or "pairs")
elif bbox_pairs:
cards_for_block = [
{"front": p["es"], "back": p["en"]}
for p in bbox_pairs
if p.get("es") and p.get("en")
]
for p in bbox_pairs:
if p.get("es") and p.get("en"):
all_vocab_cards.append({
"front": p["es"], "back": p["en"],
"chapter": ch["number"],
"chapterTitle": ch["title"],
"section": current_section_title,
"sourceImage": src,
})
pair_source = "bbox"
else:
cards_for_block = [{"front": c["front"], "back": c["back"]} for c in heuristic]
all_vocab_cards.extend(heuristic)
pair_source = "heuristic"
out_blocks.append({
"kind": "vocab_table",
"sourceImage": src,
"ocrLines": merged_lines,
"ocrConfidence": merged_conf,
"cardCount": len(cards_for_block),
"cards": cards_for_block,
"columnCount": bbox.get("columnCount", 2) if isinstance(bbox, dict) else 2,
"source": pair_source,
"bookPage": book_page,
"repairs": repairs,
"tableKind": llm_kind,
})
continue
if k == "exercise":
ans = answers.get(block["id"])
# EPUB image OCR (if any image refs)
image_ocr_lines: list = []
for src in block.get("image_refs", []):
ee = epub_ocr.get(src)
if ee:
image_ocr_lines.extend(ee.get("lines", []))
# Add PDF-page OCR for that page if available
bp = extract_book_page(src)
if bp and pdf_pages.get(bp):
# Only add lines not already present from EPUB OCR
pdf_lines = pdf_pages[bp]["lines"]
for line in pdf_lines:
line = line.strip()
if not line or line in image_ocr_lines:
continue
if line in narrative_set:
continue
image_ocr_lines.append(line)
prompts = [p for p in block.get("prompts", []) if p.strip()]
extras = [e for e in block.get("extra", []) if e.strip()]
if not prompts and image_ocr_lines:
# Extract numbered lines from OCR
for line in image_ocr_lines:
m = re.match(r"^(\d+)[.)]\s*(.+)", line.strip())
if m:
prompts.append(f"{m.group(1)}. {m.group(2)}")
sub = ans["subparts"] if ans else []
answer_items = []
for sp in sub:
for it in sp["items"]:
answer_items.append({
"label": sp["label"],
"number": it["number"],
"answer": it["answer"],
"alternates": it["alternates"],
})
out_blocks.append({
"kind": "exercise",
"id": block["id"],
"ansAnchor": block.get("ans_anchor", ""),
"instruction": clean_instruction(block.get("instruction", "")),
"extra": extras,
"prompts": prompts,
"ocrLines": image_ocr_lines,
"freeform": ans["freeform"] if ans else False,
"answerItems": answer_items,
"answerRaw": ans["raw"] if ans else "",
"answerSubparts": sub,
})
continue
out_blocks.append(block)
book_chapters.append({
"id": ch["id"],
"number": ch["number"],
"title": ch["title"],
"part": ch.get("part"),
"blocks": out_blocks,
})
book = {
"courseName": COURSE_NAME,
"totalChapters": len(book_chapters),
"totalExercises": sum(1 for ch in book_chapters for b in ch["blocks"] if b["kind"] == "exercise"),
"totalVocabTables": sum(1 for ch in book_chapters for b in ch["blocks"] if b["kind"] == "vocab_table"),
"totalVocabCards": len(all_vocab_cards),
"parts": chapters_data.get("part_memberships", {}),
"chapters": book_chapters,
"sources": {
"epub_images_ocr": bool(epub_ocr),
"pdf_pages_ocr": bool(pdf_ocr_raw),
"pdf_pages_mapped": len(pdf_pages),
},
}
OUT_BOOK.write_text(json.dumps(book, ensure_ascii=False))
vocab_by_chapter: dict = {}
for card in all_vocab_cards:
vocab_by_chapter.setdefault(card["chapter"], []).append(card)
OUT_VOCAB.write_text(json.dumps({
"courseName": COURSE_NAME,
"chapters": [
{"chapter": n, "cards": cs}
for n, cs in sorted(vocab_by_chapter.items())
],
}, ensure_ascii=False, indent=2))
print(f"Wrote {OUT_BOOK}")
print(f"Wrote {OUT_VOCAB}")
print(f"Chapters: {book['totalChapters']}")
print(f"Exercises: {book['totalExercises']}")
print(f"Vocab tables: {book['totalVocabTables']}")
print(f"Vocab cards (derived): {book['totalVocabCards']}")
print(f"PDF hits vs misses: {pdf_hits} / {pdf_misses}")
if __name__ == "__main__":
main()
@@ -0,0 +1,232 @@
#!/usr/bin/env swift
// Bounding-box OCR over every vocab image, producing SpanishEnglish pairs.
// Much higher accuracy than the flat-OCR block-alternation heuristic because
// we use each recognized line's position on the page: rows are clustered by
// Y-coordinate and cells within a row are split by the biggest X gap.
//
// Usage: swift ocr_all_vocab.swift <image_list.json> <oebps_dir> <output.json>
import Foundation
import Vision
import AppKit
guard CommandLine.arguments.count >= 4 else {
print("Usage: swift ocr_all_vocab.swift <image_list.json> <oebps_dir> <output.json>")
exit(1)
}
let imageListURL = URL(fileURLWithPath: CommandLine.arguments[1])
let oebpsDir = URL(fileURLWithPath: CommandLine.arguments[2])
let outputURL = URL(fileURLWithPath: CommandLine.arguments[3])
guard let listData = try? Data(contentsOf: imageListURL),
let imageNames = try? JSONDecoder().decode([String].self, from: listData) else {
print("Could not load image list at \(imageListURL.path)")
exit(1)
}
print("Processing \(imageNames.count) images...")
struct RecognizedLine {
let text: String
let cx: Double
let cy: Double
let confidence: Double
}
struct Pair: Encodable {
var es: String
var en: String
var confidence: Double
}
struct ImageResult: Encodable {
var pairs: [Pair]
var columnCount: Int
var strategy: String
var lineCount: Int
}
let spanishAccents = Set<Character>(["á","é","í","ó","ú","ñ","ü","Á","É","Í","Ó","Ú","Ñ","Ü","¿","¡"])
let spanishArticles: Set<String> = ["el","la","los","las","un","una","unos","unas"]
let englishStarters: Set<String> = ["the","a","an","to","my","his","her","our","their","your"]
let englishOnly: Set<String> = ["the","he","she","it","we","they","is","are","was","were","been","have","has","had","will","would"]
func classify(_ s: String) -> String {
let lower = s.lowercased()
if lower.contains(where: { spanishAccents.contains($0) }) { return "es" }
let first = lower.split(separator: " ").first.map(String.init)?.trimmingCharacters(in: .punctuationCharacters) ?? ""
if spanishArticles.contains(first) { return "es" }
if englishStarters.contains(first) || englishOnly.contains(first) { return "en" }
return "?"
}
func recognize(_ cgImage: CGImage) -> [RecognizedLine] {
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
let req = VNRecognizeTextRequest()
req.recognitionLevel = .accurate
req.recognitionLanguages = ["es-ES", "es", "en-US"]
req.usesLanguageCorrection = true
if #available(macOS 13.0, *) { req.automaticallyDetectsLanguage = true }
try? handler.perform([req])
var out: [RecognizedLine] = []
for obs in req.results ?? [] {
guard let top = obs.topCandidates(1).first else { continue }
let s = top.string.trimmingCharacters(in: .whitespaces)
if s.isEmpty { continue }
let bb = obs.boundingBox
out.append(RecognizedLine(
text: s,
cx: Double(bb.origin.x + bb.width / 2),
cy: Double(1.0 - (bb.origin.y + bb.height / 2)),
confidence: Double(top.confidence)
))
}
return out
}
/// Split a sorted-by-X line group into cells by finding the largest gap(s).
/// `desiredCells` = 2 for 2-col, 4 for 2-pair, etc.
func splitRow(_ lines: [RecognizedLine], into desiredCells: Int) -> [String] {
guard lines.count >= desiredCells else {
// Merge into fewer cells: just concatenate left-to-right.
return [lines.map(\.text).joined(separator: " ")]
}
let sorted = lines.sorted { $0.cx < $1.cx }
// Find (desiredCells - 1) biggest gaps
var gaps: [(idx: Int, gap: Double)] = []
for i in 1..<sorted.count {
gaps.append((i, sorted[i].cx - sorted[i - 1].cx))
}
let splitAt = gaps.sorted { $0.gap > $1.gap }
.prefix(desiredCells - 1)
.map(\.idx)
.sorted()
var cells: [[RecognizedLine]] = []
var start = 0
for s in splitAt {
cells.append(Array(sorted[start..<s]))
start = s
}
cells.append(Array(sorted[start..<sorted.count]))
return cells.map { $0.map(\.text).joined(separator: " ").trimmingCharacters(in: .whitespaces) }
}
/// Cluster lines into rows by Y proximity. Returns rows in top-to-bottom order.
func groupRows(_ lines: [RecognizedLine], tol: Double = 0.025) -> [[RecognizedLine]] {
let sorted = lines.sorted { $0.cy < $1.cy }
var rows: [[RecognizedLine]] = []
var current: [RecognizedLine] = []
for l in sorted {
if let last = current.last, abs(l.cy - last.cy) > tol {
rows.append(current)
current = [l]
} else {
current.append(l)
}
}
if !current.isEmpty { rows.append(current) }
return rows
}
/// Detect likely column count: look at how many x-cluster peaks exist across all rows.
/// Clusters X-coords from all lines into buckets of 10% width.
func detectColumnCount(_ lines: [RecognizedLine]) -> Int {
guard !lines.isEmpty else { return 2 }
let step = 0.10
var buckets = [Int](repeating: 0, count: Int(1.0 / step) + 1)
for l in lines {
let b = min(max(0, Int(l.cx / step)), buckets.count - 1)
buckets[b] += 1
}
// A peak = a bucket with count > 10% of total lines
let threshold = max(2, lines.count / 10)
let peaks = buckets.filter { $0 >= threshold }.count
// Most tables are 2-col (peaks = 2). Some 4-col (2 ES/EN pairs side by side peaks = 4).
// Roman/decorative layouts may show 1 peak; treat as 2.
switch peaks {
case 0, 1, 2: return 2
case 3: return 3
default: return 4
}
}
/// Merge label-less cells into SpanishEnglish pairs.
/// `cells` is a row's cells (length = columnCount). For N=2, [es, en]. For N=4,
/// [es1, en1, es2, en2] (two pairs). For N=3, [es, en_short, en_long] (rare, merge).
func cellsToPairs(_ cells: [String], columnCount: Int) -> [(String, String)] {
switch columnCount {
case 2 where cells.count >= 2:
return [(cells[0], cells[1])]
case 3 where cells.count >= 3:
// 3-col source: es | en | en-alternate. Keep all three by merging EN sides.
return [(cells[0], [cells[1], cells[2]].joined(separator: " / "))]
case 4 where cells.count >= 4:
return [(cells[0], cells[1]), (cells[2], cells[3])]
default:
if cells.count >= 2 { return [(cells[0], cells.dropFirst().joined(separator: " "))] }
return []
}
}
/// Swap pair if orientation is backwards (English on left, Spanish on right).
func orientPair(_ pair: (String, String)) -> (String, String) {
let (a, b) = pair
let ca = classify(a), cb = classify(b)
if ca == "en" && cb == "es" { return (b, a) }
return pair
}
var results: [String: ImageResult] = [:]
var processed = 0
let startTime = Date()
for name in imageNames {
processed += 1
let url = oebpsDir.appendingPathComponent(name)
guard let nsImg = NSImage(contentsOf: url),
let tiff = nsImg.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff),
let cg = rep.cgImage else {
continue
}
let lines = recognize(cg)
if lines.isEmpty {
results[name] = ImageResult(pairs: [], columnCount: 2, strategy: "empty", lineCount: 0)
continue
}
let columnCount = detectColumnCount(lines)
let rows = groupRows(lines, tol: 0.025)
var pairs: [Pair] = []
for row in rows {
guard row.count >= 2 else { continue }
let cells = splitRow(row, into: columnCount)
let rawPairs = cellsToPairs(cells, columnCount: columnCount)
for p in rawPairs {
let (es, en) = orientPair(p)
if es.count < 1 || en.count < 1 { continue }
let avgConf = row.reduce(0.0) { $0 + $1.confidence } / Double(row.count)
pairs.append(Pair(es: es, en: en, confidence: avgConf))
}
}
results[name] = ImageResult(
pairs: pairs,
columnCount: columnCount,
strategy: "bbox-row-split",
lineCount: lines.count
)
if processed % 50 == 0 || processed == imageNames.count {
let elapsed = Date().timeIntervalSince(startTime)
let rate = Double(processed) / max(elapsed, 0.001)
let eta = Double(imageNames.count - processed) / max(rate, 0.001)
print(String(format: "%d/%d %.1f img/s eta %.0fs", processed, imageNames.count, rate, eta))
}
}
let enc = JSONEncoder()
enc.outputFormatting = [.sortedKeys]
try enc.encode(results).write(to: outputURL)
let totalPairs = results.values.reduce(0) { $0 + $1.pairs.count }
let emptyTables = results.values.filter { $0.pairs.isEmpty }.count
print("Wrote \(results.count) results, \(totalPairs) total pairs, \(emptyTables) unpaired")
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env swift
// OCR every JPG in the given input directory using the macOS Vision framework.
// Output: JSON map of { "<filename>": { "lines": [...], "confidence": Double } }
//
// Usage: swift ocr_images.swift <input_dir> <output_json>
// Example: swift ocr_images.swift ../../../epub_extract/OEBPS ocr.json
import Foundation
import Vision
import AppKit
guard CommandLine.arguments.count >= 3 else {
print("Usage: swift ocr_images.swift <input_dir> <output_json>")
exit(1)
}
let inputDir = URL(fileURLWithPath: CommandLine.arguments[1])
let outputURL = URL(fileURLWithPath: CommandLine.arguments[2])
// Skip images that are icons/inline markers not real content
let skipSubstrings = ["Common", "cover", "title"]
let fileManager = FileManager.default
guard let enumerator = fileManager.enumerator(at: inputDir, includingPropertiesForKeys: nil) else {
print("Could not enumerate \(inputDir.path)")
exit(1)
}
var jpgs: [URL] = []
for case let url as URL in enumerator {
let name = url.lastPathComponent
guard name.hasSuffix(".jpg") || name.hasSuffix(".jpeg") || name.hasSuffix(".png") else { continue }
if skipSubstrings.contains(where: { name.contains($0) }) { continue }
jpgs.append(url)
}
jpgs.sort { $0.lastPathComponent < $1.lastPathComponent }
print("Found \(jpgs.count) images to OCR")
struct OCRResult: Encodable {
var lines: [String]
var confidence: Double
}
var results: [String: OCRResult] = [:]
let total = jpgs.count
var processed = 0
let startTime = Date()
for url in jpgs {
processed += 1
let name = url.lastPathComponent
guard let nsImage = NSImage(contentsOf: url),
let tiffData = nsImage.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData),
let cgImage = bitmap.cgImage else {
print("\(processed)/\(total) \(name) — could not load")
continue
}
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.recognitionLanguages = ["es-ES", "es", "en-US"]
request.usesLanguageCorrection = true
// For the 2020 book, automaticallyDetectsLanguage helps with mixed content
if #available(macOS 13.0, *) {
request.automaticallyDetectsLanguage = true
}
do {
try handler.perform([request])
let observations = request.results ?? []
var lines: [String] = []
var totalConfidence: Float = 0
var count = 0
for obs in observations {
if let top = obs.topCandidates(1).first {
let s = top.string.trimmingCharacters(in: .whitespaces)
if !s.isEmpty {
lines.append(s)
totalConfidence += top.confidence
count += 1
}
}
}
let avg = count > 0 ? Double(totalConfidence) / Double(count) : 0.0
results[name] = OCRResult(lines: lines, confidence: avg)
} catch {
print("\(processed)/\(total) \(name) — error: \(error)")
}
if processed % 50 == 0 || processed == total {
let elapsed = Date().timeIntervalSince(startTime)
let rate = Double(processed) / max(elapsed, 0.001)
let remaining = Double(total - processed) / max(rate, 0.001)
print(String(format: "%d/%d %.1f img/s eta %.0fs", processed, total, rate, remaining))
}
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
do {
let data = try encoder.encode(results)
try data.write(to: outputURL)
print("Wrote \(results.count) OCR entries to \(outputURL.path)")
} catch {
print("Error writing output: \(error)")
exit(1)
}

Some files were not shown because too many files have changed in this diff Show More