Vocab study — noun & adjective flashcards with CEFR level toggles

Add SRS-driven noun and adjective flashcards modeled on the existing verb
flashcard flow:

- SharedModels/Lexeme — catalog of non-verb vocab, frequency-ranked, with
  gender for nouns and optional example sentences. Seeded from a bundled
  vocab_lexemes.json built by Scripts/vocab/build_lexemes.py, which joins
  frequency.csv + es-en.data from a pinned doozan/spanish_data commit
  (CC-BY-SA: hermitdave/FrequencyWords + Wiktionary). 1,449 nouns and 600
  adjectives, each with Wiktionary-sourced gender and (where available)
  an example sentence with English translation.
- LexemeReviewCard + LexemeReviewStore — cloud-synced SM-2 SRS, keyed by
  partOfSpeech + lexemeId + drillMode so future drill modes can coexist.
- LexemeSessionQueue + LexemePool — parallel to VocabSessionQueue; fresh
  cards sort by frequency rank.
- LexemeStudyGroup — cloud-synced resumable session per
  (partOfSpeech, drillMode).
- NounFlashcardPracticeView + AdjectiveFlashcardPracticeView — same flow
  as VocabFlashcardPracticeView: English prompt → tap to reveal Spanish
  → Again/Hard/Good/Easy. Nouns reveal with their article (la taza, el
  problema) so gender is taught alongside meaning, not as a separate
  quiz. Example sentence shown when present.

CEFR-style level toggles:
- LexemeLevel enum (A1/A2/B1/B2/C1+) derived from frequencyRank with
  standard Spanish-frequency-dictionary cutoffs (250/500/1000/2000).
- UserProgress.selectedLexemeLevels — cloud-synced multi-select, defaults
  to A1+A2 on first launch.
- SettingsView gains a "Vocabulary Levels" section with five toggles; the
  existing "Levels" section is renamed "Verb Levels" for clarity.
- Due SRS cards always surface regardless of toggles. Disabling a level
  only stops new cards from that band entering the pool.

PracticeView gets "Nouns" and "Adjectives" rows under "Books".

DataLoader: new lexemeDataVersion gate that re-seeds the Lexeme table
from vocab_lexemes.json independent of book seeding. project.yml lists
the new JSON resource and the existing book_olly-vol2.json (which the
previous build was silently excluding because xcodegen rewrote the
project from project.yml).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-19 20:16:55 -05:00
parent ac84b22977
commit 7da98d786c
24 changed files with 1811 additions and 72 deletions
+122 -64
View File
@@ -10,24 +10,24 @@
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 */; };
067996B3D0D768ADBED1E52F /* AdjectiveFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53893D3A637F0F99CAAB8F31 /* AdjectiveFlashcardPracticeView.swift */; };
0A29763DF0ED8F24730BCE5A /* vocab_lexemes.json in Resources */ = {isa = PBXBuildFile; fileRef = 7C60B9668AC8A5B3424E9F9C /* vocab_lexemes.json */; };
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 */; };
0E49DCF141F81D1C3A94C459 /* VerbReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC782D61A0763072E4964B6 /* VerbReviewStore.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 */; };
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 */; };
25A02F8B96285407EFD30817 /* LexemeStudyGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC6005F9D3D462B33803E7A /* LexemeStudyGroup.swift */; };
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */; };
27BA7FA9356467846A07697D /* TypingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10C16AA6022E4742898745CE /* TypingView.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 */; };
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 */; };
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */; };
354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */; };
@@ -35,33 +35,32 @@
362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */ = {isa = PBXBuildFile; productRef = 08D6313690BEE4E2F18EADC3 /* YouTubeKit */; };
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; };
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; };
381D007E8E35E5D91CD549A3 /* ExtraStudyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2E72744C1DAF2AC07FA614F /* ExtraStudyStore.swift */; };
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; };
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D974250C396589656B8443 /* HandwritingCanvas.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 */; };
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EFFA19D0AB2528A868E8ED /* AnswerChecker.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 */; };
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA01795655C444795577A22 /* LyricsConfirmationView.swift */; };
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */; };
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; };
55FBABFE026058CFE8B40CB6 /* LexemeReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4B8AB6EC4F3EB86C732243D /* LexemeReviewStore.swift */; };
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2ACC4C35491174257770941 /* VerbReviewStore.swift */; };
5BB8A0C1A8A6374B04F50781 /* VocabMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 878D04B21589BAB7CF8EA0AF /* VocabMultipleChoicePracticeView.swift */; };
5EA89448E97D76CFC97DC4E2 /* VocabFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB6453FB9DCFAEC1C4E42D83 /* VocabFlashcardPracticeView.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 */; };
61328552866DE185B15011A9 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15AC27B1E3D332709657F20B /* StoryLibraryView.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 */; };
6262897A38854385C64EE03B /* NounFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39E0786770462A55664C838 /* NounFlashcardPracticeView.swift */; };
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
6C0C71E0D56C199C375609AC /* BookLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA4750E84A7FA51532407CF /* BookLibraryView.swift */; };
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.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 */; };
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; };
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D95887B18216FCA71643D6 /* VocabReviewView.swift */; };
@@ -69,13 +68,18 @@
81E4DB9F64F3FF3AB8BCB03A /* TextbookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */; };
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; };
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; };
84795E8F0111A3045285D579 /* VocabStudyGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = F474200CFCCCBB318720EC64 /* VocabStudyGroup.swift */; };
848BADEF0FC04EDB4644E923 /* BookVoicePickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50663C791D9673958B4D6011 /* BookVoicePickerSheet.swift */; };
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; };
877B0306A15A0AD680B361F8 /* book_olly-vol2.json in Resources */ = {isa = PBXBuildFile; fileRef = 03E33DDF3CB9AA0770DDDD8D /* book_olly-vol2.json */; };
8B393A61B32D7D3488618ADE /* BookReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20423155763A77A050727EC /* BookReaderView.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 */; };
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 */; };
962B9B4FF15EADF2A30C5743 /* VocabSessionQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841A3015E99E1E2FF19FF8EA /* VocabSessionQueue.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 */; };
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */ = {isa = PBXBuildFile; fileRef = A6EC7C278E4287D91A0DB355 /* youtube_videos.md */; };
@@ -89,7 +93,7 @@
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 */; };
AEFD25146A64DF9A07FE32B4 /* LexemeReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C03CC5EAD0DDE98C43FF91BA /* LexemeReviewCard.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 */; };
@@ -100,21 +104,24 @@
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C359C051FB157EF447561405 /* PracticeViewModel.swift */; };
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5983A534E4836F30B5281ACB /* MainTabView.swift */; };
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */; };
C384F12E910EE909AA97ECD9 /* LexemeSessionQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE79D3EC48233CAEC2E39F81 /* LexemeSessionQueue.swift */; };
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */; };
C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */; };
C7952958B55BAD218CB3ABC5 /* BookSpeechController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C57C20F4AF2CC20C80367124 /* BookSpeechController.swift */; };
C8C3880535008764B7117049 /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADCA82DDD34DF36D59BB283 /* DataLoader.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 */; };
D3328F0C9CF6A2CB154A85B3 /* GuideCrossLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6BA2ABC841F9C987AB15F67 /* GuideCrossLinks.swift */; };
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; };
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4B5204F6B8647C816814F0 /* SyncToast.swift */; };
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */; };
D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; };
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; };
DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */; };
DCA3805DCC1D03BEEDED73A8 /* ExtraStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE00D37419072ED6AC1AC63A /* ExtraStudyView.swift */; };
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 */; };
E3D9D82E54E37F9D38103FB9 /* book_olly-vol2.json in Resources */ = {isa = PBXBuildFile; fileRef = EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */; };
E7B7799BF0C2C5B77FB52987 /* BookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C168499F60BC7AFE5100C572 /* BookChapterListView.swift */; };
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; };
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */; };
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; };
@@ -155,12 +162,12 @@
/* 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>"; };
03E33DDF3CB9AA0770DDDD8D /* book_olly-vol2.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "book_olly-vol2.json"; 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>"; };
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>"; };
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; };
180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = "<group>"; };
@@ -171,16 +178,13 @@
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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
@@ -200,7 +204,9 @@
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>"; };
4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
50663C791D9673958B4D6011 /* BookVoicePickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookVoicePickerSheet.swift; sourceTree = "<group>"; };
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadService.swift; sourceTree = "<group>"; };
53893D3A637F0F99CAAB8F31 /* AdjectiveFlashcardPracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjectiveFlashcardPracticeView.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>"; };
5983A534E4836F30B5281ACB /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
@@ -212,7 +218,6 @@
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>"; };
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>"; };
70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchView.swift; sourceTree = "<group>"; };
@@ -223,58 +228,67 @@
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>"; };
79576893566932D2BE207528 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
7C60B9668AC8A5B3424E9F9C /* vocab_lexemes.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = vocab_lexemes.json; 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>"; };
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>"; };
841A3015E99E1E2FF19FF8EA /* VocabSessionQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabSessionQueue.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>"; };
878D04B21589BAB7CF8EA0AF /* VocabMultipleChoicePracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabMultipleChoicePracticeView.swift; 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>"; };
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; };
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>"; };
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>"; };
A2E72744C1DAF2AC07FA614F /* ExtraStudyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtraStudyStore.swift; sourceTree = "<group>"; };
A39E0786770462A55664C838 /* NounFlashcardPracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NounFlashcardPracticeView.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>"; };
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>"; };
AEC6005F9D3D462B33803E7A /* LexemeStudyGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LexemeStudyGroup.swift; 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>"; };
BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
C03CC5EAD0DDE98C43FF91BA /* LexemeReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LexemeReviewCard.swift; sourceTree = "<group>"; };
C168499F60BC7AFE5100C572 /* BookChapterListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookChapterListView.swift; sourceTree = "<group>"; };
C20423155763A77A050727EC /* BookReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookReaderView.swift; sourceTree = "<group>"; };
C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = "<group>"; };
C57C20F4AF2CC20C80367124 /* BookSpeechController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSpeechController.swift; sourceTree = "<group>"; };
CAA4750E84A7FA51532407CF /* BookLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookLibraryView.swift; sourceTree = "<group>"; };
CB6453FB9DCFAEC1C4E42D83 /* VocabFlashcardPracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardPracticeView.swift; sourceTree = "<group>"; };
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; };
CCC782D61A0763072E4964B6 /* VerbReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbReviewStore.swift; sourceTree = "<group>"; };
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>"; };
D4B8AB6EC4F3EB86C732243D /* LexemeReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LexemeReviewStore.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>"; };
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>"; };
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>"; };
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>"; };
E6BA2ABC841F9C987AB15F67 /* GuideCrossLinks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideCrossLinks.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>"; };
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>"; };
EE00D37419072ED6AC1AC63A /* ExtraStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtraStudyView.swift; sourceTree = "<group>"; };
EE79D3EC48233CAEC2E39F81 /* LexemeSessionQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LexemeSessionQueue.swift; sourceTree = "<group>"; };
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; };
F474200CFCCCBB318720EC64 /* VocabStudyGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabStudyGroup.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>"; };
FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabSessionQueue.swift; sourceTree = "<group>"; };
FF3475931F1AD16054741E65 /* BookChapterListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookChapterListView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -302,6 +316,7 @@
isa = PBXGroup;
children = (
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */,
03E33DDF3CB9AA0770DDDD8D /* book_olly-vol2.json */,
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */,
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */,
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */,
@@ -310,13 +325,14 @@
3644B5ED77F29A65877D926A /* reflexive_verbs.json */,
539736EB2AB8D149ED0F9C39 /* textbook_data.json */,
3540936F058728CFD87B1A1E /* textbook_vocab.json */,
7C60B9668AC8A5B3424E9F9C /* vocab_lexemes.json */,
6658C35E454C137B53FC05A4 /* youtube_videos.json */,
A6EC7C278E4287D91A0DB355 /* youtube_videos.md */,
353C5DE41FD410FA82E3AED7 /* Models */,
23B49FBE9B44D8734D96625F /* Scripts */,
1994867BC8E985795A172854 /* Services */,
3C75490F53C34A37084FF478 /* ViewModels */,
A81CA75762B08D35D5B7A44D /* Views */,
EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */,
);
path = Conjuga;
sourceTree = "<group>";
@@ -345,11 +361,15 @@
children = (
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */,
B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */,
C57C20F4AF2CC20C80367124 /* BookSpeechController.swift */,
3A96C065B8787DEC6818E497 /* ConversationService.swift */,
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
76BE2A08EC694FF784ED5575 /* DictionaryService.swift */,
A2E72744C1DAF2AC07FA614F /* ExtraStudyStore.swift */,
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
D4B8AB6EC4F3EB86C732243D /* LexemeReviewStore.swift */,
EE79D3EC48233CAEC2E39F81 /* LexemeSessionQueue.swift */,
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */,
@@ -365,25 +385,21 @@
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */,
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */,
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */,
CCC782D61A0763072E4964B6 /* VerbReviewStore.swift */,
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */,
841A3015E99E1E2FF19FF8EA /* VocabSessionQueue.swift */,
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */,
A661ADF1141176EE96774138 /* BookSpeechController.swift */,
A2ACC4C35491174257770941 /* VerbReviewStore.swift */,
FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */,
);
path = Services;
sourceTree = "<group>";
};
1ECAF79E2138DF73BB1F6403 /* Vocab */ = {
23B49FBE9B44D8734D96625F /* Scripts */ = {
isa = PBXGroup;
children = (
6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */,
15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */,
6D8FBC65B3D300DB2966E989 /* guide-enrichment */,
);
name = Vocab;
path = Vocab;
path = Scripts;
sourceTree = "<group>";
};
29F9EEAED5A6969FEDEAE227 /* Onboarding */ = {
@@ -400,14 +416,16 @@
0313D24F96E6A0039C34341F /* DailyLog.swift */,
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */,
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */,
E6BA2ABC841F9C987AB15F67 /* GuideCrossLinks.swift */,
C03CC5EAD0DDE98C43FF91BA /* LexemeReviewCard.swift */,
AEC6005F9D3D462B33803E7A /* LexemeStudyGroup.swift */,
626873572466403C0288090D /* QuizType.swift */,
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */,
69D98E1564C6538056D81200 /* TenseEndingTable.swift */,
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
DAFE27F29412021AEC57E728 /* TestResult.swift */,
E536AD1180FE10576EAC884A /* UserProgress.swift */,
A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */,
DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */,
F474200CFCCCBB318720EC64 /* VocabStudyGroup.swift */,
);
path = Models;
sourceTree = "<group>";
@@ -474,25 +492,40 @@
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
10C16AA6022E4742898745CE /* TypingView.swift */,
E8D95887B18216FCA71643D6 /* VocabReviewView.swift */,
9CD612E55440D22B877EA8FE /* Books */,
8FB89F19B33894DDF27C8EC2 /* Chat */,
895E547BEFB5D0FBF676BE33 /* Lyrics */,
43E4D263B0AF47E401A51601 /* Stories */,
74AC8A0D381958D2A14316C3 /* Books */,
1ECAF79E2138DF73BB1F6403 /* Vocab */,
730BD7F59F4C97D87EF98FB1 /* Vocab */,
);
path = Practice;
sourceTree = "<group>";
};
74AC8A0D381958D2A14316C3 /* Books */ = {
6D8FBC65B3D300DB2966E989 /* guide-enrichment */ = {
isa = PBXGroup;
children = (
340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */,
FF3475931F1AD16054741E65 /* BookChapterListView.swift */,
EDD4AF96186662567525F8C4 /* BookReaderView.swift */,
1C5F851F5C2C71C293DD9938 /* BookVoicePickerSheet.swift */,
7DE0F6354CF73BDA0CE728BA /* in */,
C36A0F3B1A4B759412ADB4E5 /* out */,
);
name = Books;
path = Books;
path = "guide-enrichment";
sourceTree = "<group>";
};
730BD7F59F4C97D87EF98FB1 /* Vocab */ = {
isa = PBXGroup;
children = (
53893D3A637F0F99CAAB8F31 /* AdjectiveFlashcardPracticeView.swift */,
A39E0786770462A55664C838 /* NounFlashcardPracticeView.swift */,
CB6453FB9DCFAEC1C4E42D83 /* VocabFlashcardPracticeView.swift */,
878D04B21589BAB7CF8EA0AF /* VocabMultipleChoicePracticeView.swift */,
);
path = Vocab;
sourceTree = "<group>";
};
7DE0F6354CF73BDA0CE728BA /* in */ = {
isa = PBXGroup;
children = (
);
path = in;
sourceTree = "<group>";
};
8102F7FA5BFE6D38B2212AD3 /* Guide */ = {
@@ -526,6 +559,17 @@
path = Chat;
sourceTree = "<group>";
};
9CD612E55440D22B877EA8FE /* Books */ = {
isa = PBXGroup;
children = (
C168499F60BC7AFE5100C572 /* BookChapterListView.swift */,
CAA4750E84A7FA51532407CF /* BookLibraryView.swift */,
C20423155763A77A050727EC /* BookReaderView.swift */,
50663C791D9673958B4D6011 /* BookVoicePickerSheet.swift */,
);
path = Books;
sourceTree = "<group>";
};
A591A3B6F1F13D23D68D7A9D = {
isa = PBXGroup;
children = (
@@ -568,17 +612,24 @@
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */,
833516C5D57F164C8660A479 /* CourseView.swift */,
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
EE00D37419072ED6AC1AC63A /* ExtraStudyView.swift */,
F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */,
496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */,
39908548430FDF01D76201FB /* TextbookChapterView.swift */,
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */,
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */,
);
path = Course;
sourceTree = "<group>";
};
C36A0F3B1A4B759412ADB4E5 /* out */ = {
isa = PBXGroup;
children = (
);
path = out;
sourceTree = "<group>";
};
F605D24E5EA11065FD18AF7E /* Products */ = {
isa = PBXGroup;
children = (
@@ -690,14 +741,15 @@
buildActionMask = 2147483647;
files = (
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */,
877B0306A15A0AD680B361F8 /* book_olly-vol2.json in Resources */,
CF9E48ADF0501FB79F3DDB7B /* conjuga_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 */,
0A29763DF0ED8F24730BCE5A /* vocab_lexemes.json in Resources */,
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */,
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */,
E3D9D82E54E37F9D38103FB9 /* book_olly-vol2.json in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -710,8 +762,14 @@
files = (
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */,
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */,
067996B3D0D768ADBED1E52F /* AdjectiveFlashcardPracticeView.swift in Sources */,
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */,
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */,
E7B7799BF0C2C5B77FB52987 /* BookChapterListView.swift in Sources */,
6C0C71E0D56C199C375609AC /* BookLibraryView.swift in Sources */,
8B393A61B32D7D3488618ADE /* BookReaderView.swift in Sources */,
C7952958B55BAD218CB3ABC5 /* BookSpeechController.swift in Sources */,
848BADEF0FC04EDB4644E923 /* BookVoicePickerSheet.swift in Sources */,
B48C0015BE53279B0631C2D7 /* ChatLibraryView.swift in Sources */,
8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */,
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */,
@@ -728,6 +786,8 @@
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */,
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */,
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */,
381D007E8E35E5D91CD549A3 /* ExtraStudyStore.swift in Sources */,
DCA3805DCC1D03BEEDED73A8 /* ExtraStudyView.swift in Sources */,
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */,
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */,
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */,
@@ -735,11 +795,16 @@
8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */,
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */,
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */,
D3328F0C9CF6A2CB154A85B3 /* GuideCrossLinks.swift in Sources */,
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */,
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */,
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
AEFD25146A64DF9A07FE32B4 /* LexemeReviewCard.swift in Sources */,
55FBABFE026058CFE8B40CB6 /* LexemeReviewStore.swift in Sources */,
C384F12E910EE909AA97ECD9 /* LexemeSessionQueue.swift in Sources */,
25A02F8B96285407EFD30817 /* LexemeStudyGroup.swift in Sources */,
A7DF435F99E66E067F2B33E1 /* ListeningView.swift in Sources */,
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */,
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */,
@@ -748,6 +813,7 @@
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */,
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */,
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */,
6262897A38854385C64EE03B /* NounFlashcardPracticeView.swift in Sources */,
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */,
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */,
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */,
@@ -787,26 +853,18 @@
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */,
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */,
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
0E49DCF141F81D1C3A94C459 /* VerbReviewStore.swift in Sources */,
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */,
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */,
5EA89448E97D76CFC97DC4E2 /* VocabFlashcardPracticeView.swift in Sources */,
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
5BB8A0C1A8A6374B04F50781 /* VocabMultipleChoicePracticeView.swift in Sources */,
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */,
962B9B4FF15EADF2A30C5743 /* VocabSessionQueue.swift in Sources */,
84795E8F0111A3045285D579 /* VocabStudyGroup.swift in Sources */,
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */,
AE156A84B4ECDB1A4A38CE88 /* ExtraStudyStore.swift in Sources */,
6F7BE3533FAF4DE1D514AA7C /* ExtraStudyView.swift in Sources */,
65382875879BD537F5358381 /* BookLibraryView.swift in Sources */,
4E00225D668FDFA3026B7627 /* BookChapterListView.swift in Sources */,
64E08FBC4B188B332F8039FD /* BookReaderView.swift in Sources */,
33BFEC0F0DEFC8A0E1FD8009 /* BookSpeechController.swift in Sources */,
2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */,
C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */,
419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */,
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */,
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */,
13A649C50A2B72662F92F0BD /* VocabSessionQueue.swift in Sources */,
6951332583F08DA6841F09FC /* VocabStudyGroup.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+3 -1
View File
@@ -73,6 +73,7 @@ struct ConjugaApp: App {
ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
LexemeReviewCard.self, LexemeStudyGroup.self,
]),
cloudKitDatabase: .private("iCloud.com.conjuga.app")
)
@@ -80,6 +81,7 @@ struct ConjugaApp: App {
for: ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
LexemeReviewCard.self, LexemeStudyGroup.self,
configurations: cloudConfig
)
@@ -253,7 +255,7 @@ struct ConjugaApp: App {
/// Clears accumulated stale schema metadata from previous container configurations.
/// Bump the version number to force another reset if the schema changes again.
private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) {
let resetVersion = 5 // bump: Book/BookChapter added to local container
let resetVersion = 6 // bump: Lexeme added to local container
let key = "localStoreResetVersion"
let defaults = UserDefaults.standard
@@ -0,0 +1,38 @@
import SwiftData
import Foundation
/// SRS record for non-verb vocab cards (nouns, adjectives, ). Keyed by
/// `(partOfSpeech, lexemeId, drillMode)` so a noun's gender drill and its
/// English-recall drill progress independently. Lives in the cloud container
/// alongside `VerbReviewCard` so vocab progress syncs across devices.
///
/// CloudKit-synced; uniqueness on `id` is enforced via fetch-or-create in
/// `LexemeReviewStore` since CloudKit forbids `@Attribute(.unique)`.
@Model
final class LexemeReviewCard {
var id: String = ""
var lexemeId: String = ""
var partOfSpeech: String = ""
var drillMode: String = ""
var easeFactor: Double = 2.5
var interval: Int = 0
var repetitions: Int = 0
var dueDate: Date = Date()
var lastReviewDate: Date?
init(lexemeId: String, partOfSpeech: String, drillMode: String) {
self.id = Self.makeId(
lexemeId: lexemeId,
partOfSpeech: partOfSpeech,
drillMode: drillMode
)
self.lexemeId = lexemeId
self.partOfSpeech = partOfSpeech
self.drillMode = drillMode
}
static func makeId(lexemeId: String, partOfSpeech: String, drillMode: String) -> String {
"\(partOfSpeech)|\(lexemeId)|\(drillMode)"
}
}
@@ -0,0 +1,101 @@
import Foundation
import SwiftData
/// Per-(POS, drillMode) active study group, mirroring `VocabStudyGroup`.
/// Keying by drill mode means a noun gender drill and an adjective agreement
/// drill can each have their own resumable session at the same time.
///
/// CloudKit-synced; uniqueness on `id` is enforced via fetch-or-create.
@Model
final class LexemeStudyGroup {
var id: String = ""
var partOfSpeech: String = ""
var drillMode: String = ""
/// JSON-encoded `[StoredLexemeEntry]` the in-session queue in order.
var entriesJSON: Data = Data()
var learnedCount: Int = 0
var createdAt: Date = Date()
init(
partOfSpeech: String,
drillMode: String,
entriesJSON: Data,
learnedCount: Int
) {
self.id = Self.activeID(partOfSpeech: partOfSpeech, drillMode: drillMode)
self.partOfSpeech = partOfSpeech
self.drillMode = drillMode
self.entriesJSON = entriesJSON
self.learnedCount = learnedCount
self.createdAt = Date()
}
static func activeID(partOfSpeech: String, drillMode: String) -> String {
"active-\(partOfSpeech)-\(drillMode)"
}
var entries: [StoredLexemeEntry] {
(try? JSONDecoder().decode([StoredLexemeEntry].self, from: entriesJSON)) ?? []
}
}
/// One lexeme's spot in the persisted study group.
struct StoredLexemeEntry: Codable {
var lexemeId: String
/// Raw value of `LexemeSessionQueue.CardState`.
var state: String
}
/// Fetch / persist / clear the active group for one `(POS, drillMode)` pair.
struct LexemeStudyGroupStore {
let context: ModelContext
let partOfSpeech: String
let drillMode: String
private var activeID: String {
LexemeStudyGroup.activeID(partOfSpeech: partOfSpeech, drillMode: drillMode)
}
func activeGroup() -> LexemeStudyGroup? {
let id = activeID
let descriptor = FetchDescriptor<LexemeStudyGroup>(
predicate: #Predicate<LexemeStudyGroup> { $0.id == id },
sortBy: [SortDescriptor(\LexemeStudyGroup.createdAt, order: .reverse)]
)
return (try? context.fetch(descriptor))?.first
}
func persist(entries: [StoredLexemeEntry], learnedCount: Int) {
let data = (try? JSONEncoder().encode(entries)) ?? Data()
let id = activeID
let descriptor = FetchDescriptor<LexemeStudyGroup>(
predicate: #Predicate<LexemeStudyGroup> { $0.id == id },
sortBy: [SortDescriptor(\LexemeStudyGroup.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(LexemeStudyGroup(
partOfSpeech: partOfSpeech,
drillMode: drillMode,
entriesJSON: data,
learnedCount: learnedCount
))
}
try? context.save()
}
func clear() {
let id = activeID
let descriptor = FetchDescriptor<LexemeStudyGroup>(
predicate: #Predicate<LexemeStudyGroup> { $0.id == id }
)
for group in (try? context.fetch(descriptor)) ?? [] {
context.delete(group)
}
try? context.save()
}
}
+33
View File
@@ -26,6 +26,10 @@ final class UserProgress {
var selectedLevelsBlob: String = ""
var enabledIrregularCategoriesBlob: String = ""
// Multi-select CEFR levels for the noun/adjective vocab catalog
// separate from the verb levels above so the two are independent.
var selectedLexemeLevelsBlob: String = ""
init() {}
var selectedVerbLevel: VerbLevel {
@@ -107,6 +111,35 @@ final class UserProgress {
selectedVerbLevels = values
}
/// CEFR-style levels currently enabled for noun + adjective flashcards.
/// First-ever read (blob empty) defaults to A1+A2 a beginner-friendly
/// starting point. Once the user touches any toggle, the blob is no
/// longer empty and exactly reflects their selection (including the
/// "all off" state, which shows the empty-state message).
var selectedLexemeLevels: Set<LexemeLevel> {
get {
if selectedLexemeLevelsBlob.isEmpty {
return [.a1, .a2]
}
let raw = decodeStringArray(from: selectedLexemeLevelsBlob, fallback: [])
return Set(raw.compactMap(LexemeLevel.init(rawValue:)))
}
set {
let sorted = newValue.map(\.rawValue)
selectedLexemeLevelsBlob = Self.encodeStringArray(sorted)
}
}
func setLexemeLevelEnabled(_ level: LexemeLevel, enabled: Bool) {
var values = selectedLexemeLevels
if enabled {
values.insert(level)
} else {
values.remove(level)
}
selectedLexemeLevels = values
}
func setIrregularCategoryEnabled(_ category: IrregularSpan.SpanCategory, enabled: Bool) {
var values = enabledIrregularCategories
if enabled {
+100 -2
View File
@@ -9,9 +9,12 @@ actor DataLoader {
static let textbookDataVersion = 14
static let textbookDataKey = "textbookDataVersion"
static let bookDataVersion = 6 // bump: BookChapter.paragraphCount added
static let bookDataVersion = 7 // Lexeme table + WordGloss.gender added
static let bookDataKey = "bookDataVersion"
static let lexemeDataVersion = 1 // initial seeded from vocab_lexemes.json
static let lexemeDataKey = "lexemeDataVersion"
/// Quick check: does the DB need seeding or course data refresh?
static func needsSeeding(container: ModelContainer) async -> Bool {
let context = ModelContext(container)
@@ -602,7 +605,8 @@ actor DataLoader {
glossary[word] = WordGloss(
baseForm: fields["baseForm"] ?? word,
english: fields["english"] ?? "",
partOfSpeech: fields["partOfSpeech"] ?? ""
partOfSpeech: fields["partOfSpeech"] ?? "",
gender: fields["gender"]
)
}
}
@@ -657,6 +661,100 @@ actor DataLoader {
return true
}
// MARK: - Lexeme catalog (Phase 3 of vocab study)
/// Re-seed the `Lexeme` catalog if the version has changed or the rows
/// are missing. The catalog is sourced from the bundled
/// `vocab_lexemes.json` (built by `Scripts/vocab/build_lexemes.py` from
/// doozan/spanish_data) independent from book seeding so a catalog
/// refresh doesn't require touching books.
static func refreshLexemesIfNeeded(container: ModelContainer) async {
let shared = UserDefaults.standard
let context = ModelContext(container)
let existingCount = (try? context.fetchCount(FetchDescriptor<Lexeme>())) ?? 0
let storedVersion = shared.integer(forKey: lexemeDataKey)
let versionCurrent = storedVersion >= lexemeDataVersion
print("[DataLoader] refreshLexemesIfNeeded: existing=\(existingCount) stored=\(storedVersion) target=\(lexemeDataVersion) versionCurrent=\(versionCurrent)")
if versionCurrent && existingCount > 0 { return }
if let existing = try? context.fetch(FetchDescriptor<Lexeme>()) {
for lexeme in existing { context.delete(lexeme) }
}
do {
try context.save()
} catch {
print("[DataLoader] ERROR: lexeme wipe save failed: \(error)")
return
}
if seedLexemesFromCatalog(context: context) {
shared.set(lexemeDataVersion, forKey: lexemeDataKey)
print("[DataLoader] Lexeme data re-seeded to version \(lexemeDataVersion)")
} else {
print("[DataLoader] Lexeme reseed produced no rows — leaving version key untouched")
}
}
/// Read `vocab_lexemes.json` from the app bundle and insert one `Lexeme`
/// per entry. Returns true when at least one row persisted.
private static func seedLexemesFromCatalog(context: ModelContext) -> Bool {
guard let url = Bundle.main.url(forResource: "vocab_lexemes", withExtension: "json") else {
print("[DataLoader] no vocab_lexemes.json bundled — skipping lexeme seed")
return false
}
guard let data = try? Data(contentsOf: url),
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
print("[DataLoader] ERROR: vocab_lexemes.json malformed")
return false
}
var inserted = 0
// Defensive: the build script already dedupes, but skip any stray
// dupes so we never throw on the unique-constraint save.
var seen: Set<String> = []
for entry in array {
guard let baseForm = entry["baseForm"] as? String, !baseForm.isEmpty,
let english = entry["english"] as? String, !english.isEmpty,
let pos = entry["partOfSpeech"] as? String, !pos.isEmpty else {
continue
}
let dedupKey = "\(pos):\(baseForm)"
if seen.contains(dedupKey) { continue }
seen.insert(dedupKey)
let lexeme = Lexeme(
id: Lexeme.makeID(sourceBookSlug: "catalog", partOfSpeech: pos, baseForm: baseForm),
partOfSpeech: pos,
baseForm: baseForm,
english: english,
gender: entry["gender"] as? String,
sourceBookSlug: "catalog",
frequencyRank: (entry["frequencyRank"] as? Int) ?? 0,
exampleES: entry["exampleES"] as? String,
exampleEN: entry["exampleEN"] as? String
)
context.insert(lexeme)
inserted += 1
}
do {
try context.save()
} catch {
print("[DataLoader] ERROR: lexeme save failed: \(error)")
return false
}
let persisted = (try? context.fetchCount(FetchDescriptor<Lexeme>())) ?? 0
guard persisted > 0 else {
print("[DataLoader] ERROR: seeded \(inserted) lexemes but persisted count is 0")
return false
}
print("Lexeme seeding complete: \(persisted) lexemes from catalog")
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
@@ -0,0 +1,61 @@
import Foundation
import SharedModels
import SwiftData
/// SRS rating for non-verb vocab cards. Mirrors `VerbReviewStore` but keyed
/// by `(partOfSpeech, lexemeId, drillMode)` so independent drills against the
/// same lexeme don't fight over one schedule.
struct LexemeReviewStore {
let context: ModelContext
@discardableResult
func fetchOrCreateReviewCard(
lexemeId: String,
partOfSpeech: String,
drillMode: String
) -> LexemeReviewCard {
let id = LexemeReviewCard.makeId(
lexemeId: lexemeId,
partOfSpeech: partOfSpeech,
drillMode: drillMode
)
let descriptor = FetchDescriptor<LexemeReviewCard>(
predicate: #Predicate<LexemeReviewCard> { $0.id == id }
)
if let existing = (try? context.fetch(descriptor))?.first {
return existing
}
let card = LexemeReviewCard(
lexemeId: lexemeId,
partOfSpeech: partOfSpeech,
drillMode: drillMode
)
context.insert(card)
return card
}
func rate(
lexemeId: String,
partOfSpeech: String,
drillMode: String,
quality: ReviewQuality
) {
let card = fetchOrCreateReviewCard(
lexemeId: lexemeId,
partOfSpeech: partOfSpeech,
drillMode: drillMode
)
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,195 @@
import Foundation
import SharedModels
import SwiftData
/// In-session learning-step queue for `Lexeme`-based vocab practice the
/// non-verb analog of `VocabSessionQueue`. Same Anki-style position-based
/// requeue: Again/Hard requeue close, Good advances state then graduates on
/// the second pass, Easy graduates immediately. `answer` returns a
/// `ReviewQuality` only when the card graduates that's the rating fed to
/// the cross-session `LexemeReviewStore`.
struct LexemeSessionQueue {
enum CardState: String {
case new
case learning
case review
}
enum Rating {
case again, hard, good, easy
}
struct Entry: Identifiable {
let id = UUID()
let lexeme: Lexeme
var state: CardState
}
let drillMode: String
private(set) var queue: [Entry]
private(set) var learnedCount: Int = 0
private let originalLexemes: [Lexeme]
init(lexemes: [Lexeme], drillMode: String) {
self.drillMode = drillMode
self.originalLexemes = lexemes
self.queue = lexemes.map { Entry(lexeme: $0, state: .new) }
}
init(entries: [(lexeme: Lexeme, state: CardState)], drillMode: String, learnedCount: Int) {
self.drillMode = drillMode
self.originalLexemes = entries.map(\.lexeme)
self.queue = entries.map { Entry(lexeme: $0.lexeme, state: $0.state) }
self.learnedCount = learnedCount
}
func snapshot() -> [(lexemeId: String, state: CardState)] {
queue.map { ($0.lexeme.id, $0.state) }
}
var current: Entry? { queue.first }
var isComplete: Bool { queue.isEmpty }
var remainingCount: Int { queue.count }
var progress: Double {
let total = learnedCount + queue.count
return total == 0 ? 1 : Double(learnedCount) / Double(total)
}
@discardableResult
mutating func answer(_ rating: Rating) -> ReviewQuality? {
guard !queue.isEmpty else { return nil }
var entry = queue.removeFirst()
switch rating {
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
}
}
mutating func restart() {
queue = originalLexemes.shuffled().map { Entry(lexeme: $0, state: .new) }
learnedCount = 0
}
private mutating func insert(_ entry: Entry, offset: Int) {
let idx = min(queue.count, offset)
queue.insert(entry, at: idx)
}
}
// MARK: - Session lexeme pool
/// Builds a session for a given POS + drill mode: due-first per
/// `LexemeReviewCard`, then fresh (never-studied) lexemes, capped.
enum LexemePool {
/// Per-session cap. 0/unset 20. Mirrors `VocabVerbPool.sessionCardLimit`.
static var sessionCardLimit: Int {
let stored = UserDefaults.standard.integer(forKey: "lexemeSessionCardLimit")
return stored == 0 ? 20 : stored
}
static func sessionLexemes(
partOfSpeech: String,
drillMode: String,
enabledLevels: Set<LexemeLevel>,
localContext: ModelContext,
cloudContext: ModelContext
) -> [Lexeme] {
let pool = fetchStudyable(partOfSpeech: partOfSpeech, context: localContext)
let descriptor = FetchDescriptor<LexemeReviewCard>(
predicate: #Predicate<LexemeReviewCard> {
$0.partOfSpeech == partOfSpeech && $0.drillMode == drillMode
}
)
let reviewCards = (try? cloudContext.fetch(descriptor)) ?? []
let cardById = Dictionary(
reviewCards.map { ($0.lexemeId, $0) },
uniquingKeysWith: { existing, _ in existing }
)
let now = Date()
var due: [(lexeme: Lexeme, dueDate: Date)] = []
var fresh: [Lexeme] = []
for lexeme in pool {
if let card = cardById[lexeme.id] {
if card.dueDate <= now {
// Due cards surface regardless of current level toggles
// SRS isn't level-gated. Already-studied cards keep
// coming back on their schedule.
due.append((lexeme, card.dueDate))
}
} else if enabledLevels.contains(LexemeLevel.level(forRank: lexeme.frequencyRank)) {
// Fresh (never-studied) cards only enter the pool from
// levels the user has on. Disabling a level is the lever
// for "don't introduce me to harder/easier words yet."
fresh.append(lexeme)
}
}
due.sort { $0.dueDate < $1.dueDate }
// Fresh cards surface in frequency order most-useful words first.
// Lexemes without a rank (frequencyRank == 0) sort last.
fresh.sort { lhs, rhs in
let l = lhs.frequencyRank == 0 ? Int.max : lhs.frequencyRank
let r = rhs.frequencyRank == 0 ? Int.max : rhs.frequencyRank
if l != r { return l < r }
return lhs.baseForm < rhs.baseForm
}
let ordered = due.map(\.lexeme) + fresh
return Array(ordered.prefix(sessionCardLimit))
}
/// Lexemes the user has already studied at least once for `(POS, drill)`,
/// most-recently-studied first. Mirrors `VocabVerbPool.reviewLearnedVerbs`.
static func reviewLearnedLexemes(
partOfSpeech: String,
drillMode: String,
localContext: ModelContext,
cloudContext: ModelContext
) -> [Lexeme] {
let descriptor = FetchDescriptor<LexemeReviewCard>(
predicate: #Predicate<LexemeReviewCard> {
$0.partOfSpeech == partOfSpeech && $0.drillMode == drillMode
}
)
let reviewCards = (try? cloudContext.fetch(descriptor)) ?? []
let sorted = reviewCards.sorted {
($0.lastReviewDate ?? .distantPast) > ($1.lastReviewDate ?? .distantPast)
}
let pool = fetchStudyable(partOfSpeech: partOfSpeech, context: localContext)
let byId = Dictionary(pool.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
return sorted.compactMap { byId[$0.lexemeId] }
}
/// Lexemes for a POS. The catalog (`vocab_lexemes.json`) only emits
/// nouns that have a known gender, so no extra filter is needed here.
private static func fetchStudyable(partOfSpeech: String, context: ModelContext) -> [Lexeme] {
let descriptor = FetchDescriptor<Lexeme>(
predicate: #Predicate<Lexeme> { $0.partOfSpeech == partOfSpeech }
)
return (try? context.fetch(descriptor)) ?? []
}
}
@@ -11,6 +11,7 @@ enum StartupCoordinator {
await DataLoader.refreshCourseDataIfNeeded(container: localContainer)
await DataLoader.refreshTextbookDataIfNeeded(container: localContainer)
await DataLoader.refreshBooksDataIfNeeded(container: localContainer)
await DataLoader.refreshLexemesIfNeeded(container: localContainer)
}
/// Recurring maintenance: legacy migrations, identity repair, cloud dedup.
@@ -302,6 +302,68 @@ struct PracticeView: View {
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Nouns
NavigationLink {
NounFlashcardPracticeView()
} label: {
HStack(spacing: 14) {
Image(systemName: "n.circle.fill")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.teal)
VStack(alignment: .leading, spacing: 2) {
Text("Nouns")
.font(.subheadline.weight(.semibold))
Text("Flashcards — English ↔ Spanish")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Adjectives
NavigationLink {
AdjectiveFlashcardPracticeView()
} label: {
HStack(spacing: 14) {
Image(systemName: "a.circle.fill")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.pink)
VStack(alignment: .leading, spacing: 2) {
Text("Adjectives")
.font(.subheadline.weight(.semibold))
Text("Flashcards — English ↔ Spanish")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Session stats summary
if viewModel.sessionTotal > 0 && !isPracticing {
VStack(spacing: 8) {
@@ -0,0 +1,290 @@
import SwiftUI
import SharedModels
import SwiftData
/// English Spanish adjective flashcards. Same flow as the noun view and
/// the verb flashcards: show the English meaning, tap to reveal the Spanish
/// base form, rate Again/Hard/Good/Easy. Agreement (gender + number) is
/// taught organically through reading and verb-flashcard examples, not as a
/// separate quiz here.
///
/// Plain `ScrollView { VStack }` no `LazyVStack`/`ScrollViewReader`.
struct AdjectiveFlashcardPracticeView: View {
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss
@State private var session: LexemeSessionQueue?
@State private var revealed: Bool = false
private var cloudContext: ModelContext { cloudModelContextProvider() }
private var currentLexeme: Lexeme? { session?.current?.lexeme }
private static let drillMode = "recall"
var body: some View {
ScrollView {
VStack(spacing: 24) {
headerBar
if let lexeme = currentLexeme {
cardContent(for: lexeme)
} else {
completionView
}
}
.padding()
.adaptiveContainer(maxWidth: 720)
}
.navigationTitle("Adjectives")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded)
.animation(.smooth, value: revealed)
.animation(.smooth, value: currentLexeme?.id)
}
// MARK: - Card
@ViewBuilder
private func cardContent(for lexeme: Lexeme) -> some View {
Text(lexeme.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if revealed {
VStack(spacing: 14) {
Text(lexeme.baseForm)
.font(.title.weight(.semibold))
.multilineTextAlignment(.center)
exampleBlock(for: lexeme)
ratingButtons(for: lexeme)
}
} else {
tapToReveal
}
}
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, minHeight: 200)
.glassEffect(in: RoundedRectangle(cornerRadius: 20))
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.smooth) { revealed = true }
}
}
@ViewBuilder
private func exampleBlock(for lexeme: Lexeme) -> some View {
if let es = lexeme.exampleES, !es.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text(es).font(.subheadline).italic()
if let en = lexeme.exampleEN, !en.isEmpty {
Text(en).font(.caption).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Header
@ViewBuilder
private var headerBar: some View {
VStack(spacing: 6) {
ProgressView(value: session?.progress ?? 0).tint(.pink)
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: - Rating
private func ratingButtons(for lexeme: Lexeme) -> 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, lexeme: lexeme)
ratingButton("Hard", color: .orange, rating: .hard, lexeme: lexeme)
ratingButton("Good", color: .green, rating: .good, lexeme: lexeme)
ratingButton("Easy", color: .blue, rating: .easy, lexeme: lexeme)
}
}
}
private func ratingButton(_ label: String, color: Color, rating: LexemeSessionQueue.Rating, lexeme: Lexeme) -> some View {
Button {
answer(rating, for: lexeme)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(color)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
private func answer(_ rating: LexemeSessionQueue.Rating, for lexeme: Lexeme) {
let graduation = session?.answer(rating)
if let graduation {
LexemeReviewStore(context: cloudContext).rate(
lexemeId: lexeme.id,
partOfSpeech: "adjective",
drillMode: Self.drillMode,
quality: graduation
)
}
persistGroup()
withAnimation(.smooth) { revealed = false }
}
// MARK: - Completion
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("Next Set", systemImage: "arrow.right")
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.pink)
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
return learned > 0 ? "Session Complete" : "Nothing Available"
}
private var completionDetail: String {
let learned = session?.learnedCount ?? 0
if learned > 0 {
return "\(learned) adjective\(learned == 1 ? "" : "s") learned"
}
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
if progress.selectedLexemeLevels.isEmpty {
return "No vocabulary levels enabled. Open Settings → Vocabulary Levels to turn some on."
}
return "No adjectives available at the enabled levels right now."
}
// MARK: - Session lifecycle
private func loadIfNeeded() {
guard session == nil else { return }
let store = LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "adjective",
drillMode: Self.drillMode
)
if let group = store.activeGroup() {
let stored = group.entries
if !stored.isEmpty {
let byId = lexemesByID(Set(stored.map(\.lexemeId)))
let entries: [(lexeme: Lexeme, state: LexemeSessionQueue.CardState)] = stored.compactMap { e in
guard let lex = byId[e.lexemeId] else { return nil }
return (lex, LexemeSessionQueue.CardState(rawValue: e.state) ?? .new)
}
if entries.count == stored.count {
session = LexemeSessionQueue(
entries: entries,
drillMode: Self.drillMode,
learnedCount: group.learnedCount
)
return
}
}
}
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let lexemes = LexemePool.sessionLexemes(
partOfSpeech: "adjective",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
persistGroup()
}
private func lexemesByID(_ ids: Set<String>) -> [String: Lexeme] {
let descriptor = FetchDescriptor<Lexeme>(
predicate: #Predicate<Lexeme> { ids.contains($0.id) }
)
let all = (try? localContext.fetch(descriptor)) ?? []
return Dictionary(all.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
}
private func persistGroup() {
guard let session else { return }
let store = LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "adjective",
drillMode: Self.drillMode
)
if session.isComplete {
store.clear()
} else {
let entries = session.snapshot().map {
StoredLexemeEntry(lexemeId: $0.lexemeId, state: $0.state.rawValue)
}
store.persist(entries: entries, learnedCount: session.learnedCount)
}
}
private func studyAgain() {
LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "adjective",
drillMode: Self.drillMode
).clear()
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let lexemes = LexemePool.sessionLexemes(
partOfSpeech: "adjective",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
revealed = false
persistGroup()
}
}
@@ -0,0 +1,306 @@
import SwiftUI
import SharedModels
import SwiftData
/// English Spanish noun flashcards. Same flow as `VocabFlashcardPracticeView`
/// for verbs: show the English meaning, tap to reveal the Spanish word, rate
/// Again/Hard/Good/Easy. The Spanish reveal shows the word with its article
/// (`la taza`, `el problema`) so gender is taught alongside meaning instead
/// of being a separate "el or la?" quiz.
///
/// Plain `ScrollView { VStack }` no `LazyVStack`/`ScrollViewReader` (keeps
/// it out of the books-reader layout-loop class of bug).
struct NounFlashcardPracticeView: View {
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss
@State private var session: LexemeSessionQueue?
@State private var revealed: Bool = false
private var cloudContext: ModelContext { cloudModelContextProvider() }
private var currentLexeme: Lexeme? { session?.current?.lexeme }
/// Single drill mode for now meaning recall. The `LexemeReviewCard` /
/// `LexemeStudyGroup` IDs are keyed by drillMode so other modes can be
/// added later without colliding with this one.
private static let drillMode = "recall"
var body: some View {
ScrollView {
VStack(spacing: 24) {
headerBar
if let lexeme = currentLexeme {
cardContent(for: lexeme)
} else {
completionView
}
}
.padding()
.adaptiveContainer(maxWidth: 720)
}
.navigationTitle("Nouns")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded)
.animation(.smooth, value: revealed)
.animation(.smooth, value: currentLexeme?.id)
}
// MARK: - Card
@ViewBuilder
private func cardContent(for lexeme: Lexeme) -> some View {
Text(lexeme.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if revealed {
VStack(spacing: 14) {
Text(formattedSpanish(lexeme))
.font(.title.weight(.semibold))
.multilineTextAlignment(.center)
exampleBlock(for: lexeme)
ratingButtons(for: lexeme)
}
} else {
tapToReveal
}
}
/// Show the noun with its article so gender comes along free.
private func formattedSpanish(_ lexeme: Lexeme) -> String {
guard let g = lexeme.gender, !g.isEmpty else { return lexeme.baseForm }
let article: String
switch g {
case "f": article = "la"
case "m/f": article = "el/la"
default: article = "el"
}
return "\(article) \(lexeme.baseForm)"
}
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, minHeight: 200)
.glassEffect(in: RoundedRectangle(cornerRadius: 20))
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.smooth) { revealed = true }
}
}
@ViewBuilder
private func exampleBlock(for lexeme: Lexeme) -> some View {
if let es = lexeme.exampleES, !es.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text(es).font(.subheadline).italic()
if let en = lexeme.exampleEN, !en.isEmpty {
Text(en).font(.caption).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Header
@ViewBuilder
private var headerBar: some View {
VStack(spacing: 6) {
ProgressView(value: session?.progress ?? 0).tint(.teal)
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: - Rating
private func ratingButtons(for lexeme: Lexeme) -> 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, lexeme: lexeme)
ratingButton("Hard", color: .orange, rating: .hard, lexeme: lexeme)
ratingButton("Good", color: .green, rating: .good, lexeme: lexeme)
ratingButton("Easy", color: .blue, rating: .easy, lexeme: lexeme)
}
}
}
private func ratingButton(_ label: String, color: Color, rating: LexemeSessionQueue.Rating, lexeme: Lexeme) -> some View {
Button {
answer(rating, for: lexeme)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(color)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
private func answer(_ rating: LexemeSessionQueue.Rating, for lexeme: Lexeme) {
let graduation = session?.answer(rating)
if let graduation {
LexemeReviewStore(context: cloudContext).rate(
lexemeId: lexeme.id,
partOfSpeech: "noun",
drillMode: Self.drillMode,
quality: graduation
)
}
persistGroup()
withAnimation(.smooth) { revealed = false }
}
// MARK: - Completion
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("Next Set", systemImage: "arrow.right")
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.teal)
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
return learned > 0 ? "Session Complete" : "Nothing Available"
}
private var completionDetail: String {
let learned = session?.learnedCount ?? 0
if learned > 0 {
return "\(learned) noun\(learned == 1 ? "" : "s") learned"
}
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
if progress.selectedLexemeLevels.isEmpty {
return "No vocabulary levels enabled. Open Settings → Vocabulary Levels to turn some on."
}
return "No nouns available at the enabled levels right now."
}
// MARK: - Session lifecycle
private func loadIfNeeded() {
guard session == nil else { return }
let store = LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "noun",
drillMode: Self.drillMode
)
if let group = store.activeGroup() {
let stored = group.entries
if !stored.isEmpty {
let byId = lexemesByID(Set(stored.map(\.lexemeId)))
let entries: [(lexeme: Lexeme, state: LexemeSessionQueue.CardState)] = stored.compactMap { e in
guard let lex = byId[e.lexemeId] else { return nil }
return (lex, LexemeSessionQueue.CardState(rawValue: e.state) ?? .new)
}
if entries.count == stored.count {
session = LexemeSessionQueue(
entries: entries,
drillMode: Self.drillMode,
learnedCount: group.learnedCount
)
return
}
}
}
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let lexemes = LexemePool.sessionLexemes(
partOfSpeech: "noun",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
persistGroup()
}
private func lexemesByID(_ ids: Set<String>) -> [String: Lexeme] {
let descriptor = FetchDescriptor<Lexeme>(
predicate: #Predicate<Lexeme> { ids.contains($0.id) }
)
let all = (try? localContext.fetch(descriptor)) ?? []
return Dictionary(all.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
}
private func persistGroup() {
guard let session else { return }
let store = LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "noun",
drillMode: Self.drillMode
)
if session.isComplete {
store.clear()
} else {
let entries = session.snapshot().map {
StoredLexemeEntry(lexemeId: $0.lexemeId, state: $0.state.rawValue)
}
store.persist(entries: entries, learnedCount: session.learnedCount)
}
}
private func studyAgain() {
LexemeStudyGroupStore(
context: cloudContext,
partOfSpeech: "noun",
drillMode: Self.drillMode
).clear()
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let lexemes = LexemePool.sessionLexemes(
partOfSpeech: "noun",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
revealed = false
persistGroup()
}
}
@@ -72,11 +72,30 @@ struct SettingsView: View {
))
}
} header: {
Text("Levels")
Text("Verb Levels")
} footer: {
Text("Practice pulls only from verbs whose level is enabled. Turn on multiple to mix.")
}
Section {
ForEach(LexemeLevel.allCases, id: \.self) { level in
Toggle(level.displayName, isOn: Binding(
get: {
progress?.selectedLexemeLevels.contains(level) ?? false
},
set: { enabled in
guard let progress else { return }
progress.setLexemeLevelEnabled(level, enabled: enabled)
saveProgress()
}
))
}
} header: {
Text("Vocabulary Levels")
} footer: {
Text("Noun and adjective flashcards pull only from the enabled CEFR levels. New first-time installs default to A1 + A2.")
}
Section {
ForEach(TenseInfo.all) { tense in
Toggle(tense.english, isOn: Binding(
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -60,10 +60,15 @@ For EACH word, produce one entry:
dictionary sense.
- partOfSpeech: one of verb, noun, adjective, adverb, pronoun, preposition,
conjunction, article, interjection, numeral, proper noun, other.
- gender: ONLY for `partOfSpeech == "noun"`. "m" for masculine, "f" for
feminine, "m/f" for nouns that take either article (estudiante, artista).
OMIT the field entirely (or use null) for non-nouns and for cases where the
gender is genuinely unknowable from context. Don't guess for non-nouns.
Write the output file as JSON with this exact shape:
{{"jobId": "<the jobId from the input>", "entries": [
{{"word": "...", "baseForm": "...", "english": "...", "partOfSpeech": "..."}}
{{"word": "...", "baseForm": "...", "english": "...",
"partOfSpeech": "...", "gender": "m"}}
]}}
`entries` MUST contain exactly one object per input word, cover every word, and
+5 -1
View File
@@ -109,11 +109,15 @@ def main() -> None:
word = (entry.get("word") or "").strip()
if not word:
continue
glossary[word] = {
gloss_entry: dict = {
"baseForm": entry.get("baseForm") or word,
"english": entry.get("english") or "",
"partOfSpeech": entry.get("partOfSpeech") or "",
}
gender = entry.get("gender")
if isinstance(gender, str) and gender.strip():
gloss_entry["gender"] = gender.strip()
glossary[word] = gloss_entry
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:
+1
View File
@@ -0,0 +1 @@
.cache/
+62
View File
@@ -0,0 +1,62 @@
# Vocab catalog build
`build_lexemes.py` produces `Conjuga/vocab_lexemes.json`, the bundled catalog
of frequency-ranked Spanish nouns and adjectives that powers the Noun /
Adjective flashcard study modes.
## Run
```sh
python3 build_lexemes.py
```
Downloads `frequency.csv` + `es-en.data` from a pinned commit of
[`doozan/spanish_data`](https://github.com/doozan/spanish_data), caches them
under `.cache/<commit>/`, joins them, and writes the JSON. Re-running is
fast — only the join step happens after the first download.
Override defaults:
```sh
python3 build_lexemes.py --max-nouns 3000 --max-adjectives 1000
python3 build_lexemes.py --output /tmp/vocab.json
```
## Data sources & attribution
All datasets are CC-licensed; the bundled catalog inherits CC-BY-SA. Credit
in the app's About screen must read:
> Vocabulary data: Wiktionary (CC-BY-SA), OpenSubtitles via FrequencyWords
> (CC-BY-SA 3.0).
- **`frequency.csv`** — derived from
[hermitdave/FrequencyWords](https://github.com/hermitdave/FrequencyWords)
(OpenSubtitles corpus), packaged by doozan. License: CC-BY-SA 3.0.
- **`es-en.data`** — Spanish→English Wiktionary export in the
[`enwiktionary_wordlist`](https://github.com/doozan/enwiktionary_wordlist)
format. License: CC-BY-SA.
The pinned doozan commit is at the top of `build_lexemes.py`
(`DOOZAN_COMMIT`). Bump it to refresh; the cache key includes the commit so
old data is auto-replaced.
## Output shape
```json
[
{
"baseForm": "casa",
"english": "house",
"partOfSpeech": "noun",
"gender": "f",
"frequencyRank": 142,
"exampleES": "La casa es grande",
"exampleEN": "The house is big"
},
...
]
```
Sorted by `frequencyRank` ascending so the fresh-card path in `LexemePool`
surfaces the most useful words first.
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""Build Conjuga/vocab_lexemes.json from doozan/spanish_data.
Joins doozan's frequency.csv (CC-BY-SA 3.0, OpenSubtitles via FrequencyWords)
with es-en.data (CC-BY-SA, Wiktionary) into a single bundled JSON catalog of
the highest-frequency Spanish nouns and adjectives — each row carries the
lemma, English gloss, gender (for nouns), frequency rank, and an example
sentence with translation when Wiktionary has one.
The app's DataLoader.seedLexemesFromCatalog reads this file at startup to
populate the Lexeme table that powers Noun / Adjective flashcard study.
Usage:
python3 build_lexemes.py [--max-nouns N] [--max-adjectives N]
[--output PATH] [--cache-dir PATH]
Pinned doozan commit: aeac698949e7b27112056ee8d72f70f853cd1ef9 (2026-05-01)
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
import urllib.request
from pathlib import Path
DOOZAN_COMMIT = "aeac698949e7b27112056ee8d72f70f853cd1ef9"
BASE_URL = f"https://raw.githubusercontent.com/doozan/spanish_data/{DOOZAN_COMMIT}"
FILES = {
"frequency.csv": f"{BASE_URL}/frequency.csv",
"es-en.data": f"{BASE_URL}/es-en.data",
}
# Both frequency.csv and es-en.data use short POS codes (`n`, `adj`); we keep
# the same codes for the join. The output JSON uses the longer names the
# app's Lexeme model expects.
JOIN_POS = {"n", "adj"}
OUTPUT_POS = {"n": "noun", "adj": "adjective"}
def fetch(name: str, url: str, cache_dir: Path) -> Path:
"""Download once; reuse local cache on subsequent runs."""
cache_dir.mkdir(parents=True, exist_ok=True)
out = cache_dir / name
if out.exists() and out.stat().st_size > 0:
return out
print(f" downloading {name} ({url}) ...", file=sys.stderr)
with urllib.request.urlopen(url) as resp, open(out, "wb") as fh:
fh.write(resp.read())
return out
def load_frequency(path: Path, *, keep_pos: set[str]) -> list[dict]:
"""Read frequency.csv → list of {lemma, pos, rank} for the POSes we care
about. Rank is the row index (1-based), which matches frequency-descending
order in the source file."""
rows: list[dict] = []
with open(path, encoding="utf-8") as fh:
reader = csv.DictReader(fh)
for i, row in enumerate(reader):
pos = (row.get("pos") or "").strip()
if pos not in keep_pos:
continue
flags = (row.get("flags") or "").strip()
if "DUPLICATE" in flags or "NOUSAGE" in flags:
continue
lemma = (row.get("spanish") or "").strip()
if not lemma:
continue
rows.append({"lemma": lemma, "pos": pos, "rank": i + 1})
return rows
def load_es_en(path: Path) -> dict[tuple[str, str], dict]:
"""Parse es-en.data → {(lemma, pos): {gender, english, exampleES, exampleEN}}.
A single `_____`-delimited block can hold multiple `pos:` sub-entries
for the same lemma (e.g. `rojo` is both an adjective ("red") and a
masculine noun ("a red one"); `mano` has two noun senses with different
genders). We commit each sub-entry when we see the next `pos:` line, so
`(lemma, pos)` pairs don't get clobbered by later same-block sub-entries.
First-sense-wins on duplicate keys, which aligns with Wiktionary listing
the most-common meaning first.
"""
entries: dict[tuple[str, str], dict] = {}
lemma = pos = gender = english = ex_es = ex_en = None
next_is_lemma = False
def commit_subentry() -> None:
nonlocal pos, gender, english, ex_es, ex_en
if lemma and pos and english:
key = (lemma, pos)
if key not in entries:
entries[key] = {
"gender": gender,
"english": english,
"exampleES": ex_es,
"exampleEN": ex_en,
}
pos = gender = english = ex_es = ex_en = None
def reset_entry() -> None:
nonlocal lemma
commit_subentry()
lemma = None
with open(path, encoding="utf-8") as fh:
for raw in fh:
line = raw.rstrip("\n")
stripped = line.lstrip()
if stripped == "_____":
reset_entry()
next_is_lemma = True
continue
if next_is_lemma:
lemma = stripped
next_is_lemma = False
continue
if stripped.startswith("pos: "):
# Starting a new sub-entry for the current lemma; commit the
# previous sub-entry's state before resetting.
commit_subentry()
pos = stripped[5:].strip()
elif stripped.startswith("g: "):
gender = stripped[3:].strip()
elif stripped.startswith("gloss: "):
if english is None:
english = stripped[7:].strip()
elif stripped.startswith("ex: "):
if ex_es is None:
ex_es = stripped[4:].strip()
elif stripped.startswith("eng: "):
if ex_en is None:
ex_en = stripped[5:].strip()
reset_entry()
return entries
def normalize_gender(g: str | None) -> str | None:
"""Reduce Wiktionary gender codes to {m, f, m/f, None}.
`mp` (masculine plural) / `fp` (feminine plural) are inherently-plural
nouns (gafas, pantalones); they don't fit the singular el/la drill cleanly
in v1, so we drop them here and the entry is filtered out upstream.
"""
if not g:
return None
g = g.strip()
if g in ("m", "f"):
return g
if g in ("mf", "m/f", "m, f", "f, m"):
return "m/f"
return None
def build(args) -> None:
cache = Path(args.cache_dir).expanduser()
paths = {name: fetch(name, url, cache) for name, url in FILES.items()}
print(
f"Reading frequency.csv (top {args.max_nouns} nouns, "
f"top {args.max_adjectives} adjectives) ...",
file=sys.stderr,
)
rows = load_frequency(paths["frequency.csv"], keep_pos=JOIN_POS)
nouns = [r for r in rows if r["pos"] == "n"][: args.max_nouns]
adjs = [r for r in rows if r["pos"] == "adj"][: args.max_adjectives]
print(f" candidates: {len(nouns)} nouns, {len(adjs)} adjectives", file=sys.stderr)
print("Parsing es-en.data ...", file=sys.stderr)
es_en = load_es_en(paths["es-en.data"])
print(f" {len(es_en)} (lemma, pos) entries", file=sys.stderr)
out: list[dict] = []
skipped_no_entry = 0
skipped_no_english = 0
skipped_no_gender = 0
for source_rows in (nouns, adjs):
for r in source_rows:
short_pos = r["pos"]
output_pos = OUTPUT_POS[short_pos]
entry = es_en.get((r["lemma"], short_pos))
if not entry:
skipped_no_entry += 1
continue
english = entry.get("english")
if not english:
skipped_no_english += 1
continue
gender = normalize_gender(entry.get("gender")) if short_pos == "n" else None
if short_pos == "n" and gender is None:
# Drill needs gender; if Wiktionary doesn't have it, skip.
skipped_no_gender += 1
continue
out.append({
"baseForm": r["lemma"],
"english": english,
"partOfSpeech": output_pos,
"gender": gender,
"frequencyRank": r["rank"],
"exampleES": entry.get("exampleES"),
"exampleEN": entry.get("exampleEN"),
})
out.sort(key=lambda e: e["frequencyRank"])
out_path = Path(args.output).expanduser()
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w", encoding="utf-8") as fh:
json.dump(out, fh, ensure_ascii=False, separators=(",", ":"))
fh.write("\n")
noun_count = sum(1 for e in out if e["partOfSpeech"] == "noun")
adj_count = sum(1 for e in out if e["partOfSpeech"] == "adjective")
print(
f"Wrote {out_path}{noun_count} nouns, {adj_count} adjectives "
f"({len(out)} total, {out_path.stat().st_size:,} bytes)",
file=sys.stderr,
)
print(
f" skipped: no es-en entry={skipped_no_entry}, "
f"no english={skipped_no_english}, "
f"no gender={skipped_no_gender}",
file=sys.stderr,
)
def main() -> None:
here = Path(__file__).resolve().parent
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--max-nouns", type=int, default=1500)
parser.add_argument("--max-adjectives", type=int, default=600)
parser.add_argument(
"--output",
default=str(here / ".." / ".." / "Conjuga" / "vocab_lexemes.json"),
)
parser.add_argument(
"--cache-dir",
default=str(here / ".cache" / DOOZAN_COMMIT[:8]),
)
build(parser.parse_args())
if __name__ == "__main__":
main()
@@ -44,15 +44,24 @@ public final class Book {
}
/// One glossary entry: a word's dictionary base form, English meaning, and
/// part of speech, translated in the book's context at import time.
/// part of speech, translated in the book's context at import time. `gender`
/// is populated by the glossary pipeline for nouns ("m"/"f"/"m/f"); nil for
/// non-nouns or when the pipeline hasn't been re-run yet.
public struct WordGloss: Codable, Hashable, Sendable {
public let baseForm: String
public let english: String
public let partOfSpeech: String
public let gender: String?
public init(baseForm: String, english: String, partOfSpeech: String) {
public init(
baseForm: String,
english: String,
partOfSpeech: String,
gender: String? = nil
) {
self.baseForm = baseForm
self.english = english
self.partOfSpeech = partOfSpeech
self.gender = gender
}
}
@@ -0,0 +1,91 @@
import Foundation
import SwiftData
/// A non-verb vocabulary item harvested from the books pipeline's per-book
/// glossary. Verbs keep their own richer `Verb` model `Lexeme` covers
/// nouns, adjectives, etc. so the flashcard study modes can drill the grammar
/// that's specific to each part of speech.
///
/// Identity is `"<sourceBookSlug>:<partOfSpeech>:<baseForm>"`; the seeder
/// dedupes on `(partOfSpeech, baseForm)` across books and keeps the first-
/// seen source. Lives in the LOCAL reference-data store (same place as
/// `Book`/`BookChapter`), not the cloud container.
@Model
public final class Lexeme {
@Attribute(.unique) public var id: String = ""
public var partOfSpeech: String = ""
public var baseForm: String = ""
public var english: String = ""
/// For nouns: "m", "f", or "m/f". Nil for non-nouns or when unknown.
/// The curated catalog (`vocab_lexemes.json` from doozan/spanish_data)
/// emits Wiktionary-sourced gender; `Lexeme.inferGender` provides a
/// morphology fallback if a different seeder ever lands a noun without
/// one.
public var gender: String? = nil
/// Source tag `"catalog"` for entries from `vocab_lexemes.json`, or a
/// book slug for legacy book-glossary-derived entries. Used to keep
/// catalog refreshes from wiping book-personal additions later.
public var sourceBookSlug: String = ""
/// 1-based rank in the source frequency list (lower = more common).
/// 0 means unknown/unranked. `LexemePool` sorts fresh cards by this so
/// the most-useful words surface first.
public var frequencyRank: Int = 0
/// Optional example sentence pair, shown below the answer in Recall
/// mode. Sourced from Wiktionary's `ex:`/`eng:` lines when available.
public var exampleES: String? = nil
public var exampleEN: String? = nil
public init(
id: String,
partOfSpeech: String,
baseForm: String,
english: String,
gender: String? = nil,
sourceBookSlug: String = "",
frequencyRank: Int = 0,
exampleES: String? = nil,
exampleEN: String? = nil
) {
self.id = id
self.partOfSpeech = partOfSpeech
self.baseForm = baseForm
self.english = english
self.gender = gender
self.sourceBookSlug = sourceBookSlug
self.frequencyRank = frequencyRank
self.exampleES = exampleES
self.exampleEN = exampleEN
}
public static func makeID(sourceBookSlug: String, partOfSpeech: String, baseForm: String) -> String {
"\(sourceBookSlug):\(partOfSpeech):\(baseForm)"
}
/// Best-effort gender from Spanish morphology. Used as a fallback when
/// the glossary pipeline hasn't emitted a `gender` field yet. Conservative:
/// returns nil for ambiguous endings rather than guessing wrong.
///
/// - `-ción/-sión/-dad/-tad/-tud/-umbre/-ez/-anza` feminine
/// - `-aje/-or` masculine
/// - `-ma/-pa/-ta` nil (Greek-origin masculines mix with regular -a feminines)
/// - `-a` (other) feminine
/// - `-o` masculine
/// - everything else nil
public static func inferGender(forBaseForm baseForm: String) -> String? {
let s = baseForm.lowercased()
if s.hasSuffix("ción") || s.hasSuffix("sión") || s.hasSuffix("dad") ||
s.hasSuffix("tad") || s.hasSuffix("tud") || s.hasSuffix("umbre") ||
s.hasSuffix("ez") || s.hasSuffix("anza") {
return "f"
}
if s.hasSuffix("aje") || s.hasSuffix("or") {
return "m"
}
if s.hasSuffix("ma") || s.hasSuffix("pa") || s.hasSuffix("ta") {
return nil
}
if s.hasSuffix("a") { return "f" }
if s.hasSuffix("o") { return "m" }
return nil
}
}
@@ -0,0 +1,47 @@
import Foundation
/// CEFR-style level for a `Lexeme`, derived from its `frequencyRank`. Lets
/// users gate noun/adjective flashcard sessions by level via a Settings
/// toggle. Cutoffs follow the standard Spanish-frequency-dictionary
/// convention (Davies; RAE CEFR-aligned lists).
///
/// Note: SRS is *not* level-gated. Disabling a level only stops *new*
/// cards from that band entering the session pool already-studied cards
/// keep coming back on their SM-2 schedule regardless. See
/// `LexemePool.sessionLexemes` for where the filter is applied.
public enum LexemeLevel: String, Codable, Hashable, CaseIterable, Sendable {
case a1, a2, b1, b2, c1
/// 1-based frequency rank range. `c1` is open-ended on the high end so
/// any far-tail entry has a level even if the catalog later expands.
public var rankRange: ClosedRange<Int> {
switch self {
case .a1: return 1...250
case .a2: return 251...500
case .b1: return 501...1000
case .b2: return 1001...2000
case .c1: return 2001...Int.max
}
}
public var displayName: String {
switch self {
case .a1: return "A1 — Beginner"
case .a2: return "A2 — Elementary"
case .b1: return "B1 — Intermediate"
case .b2: return "B2 — Upper-intermediate"
case .c1: return "C1+ — Advanced"
}
}
/// The level containing this frequency rank. Rank 0 (unranked) falls
/// into `c1` better to include unknown-rank lexemes when only the
/// top end is on than silently drop them.
public static func level(forRank rank: Int) -> LexemeLevel {
guard rank > 0 else { return .c1 }
for level in LexemeLevel.allCases where level.rankRange.contains(rank) {
return level
}
return .c1
}
}
@@ -39,6 +39,7 @@ public enum SharedStore {
TenseGuide.self, CourseDeck.self, VocabCard.self,
TextbookChapter.self, DownloadedVideo.self,
Book.self, BookChapter.self,
Lexeme.self,
]
}
}
+4
View File
@@ -58,6 +58,10 @@ targets:
buildPhase: resources
- path: Conjuga/textbook_vocab.json
buildPhase: resources
- path: Conjuga/book_olly-vol2.json
buildPhase: resources
- path: Conjuga/vocab_lexemes.json
buildPhase: resources
info:
path: Conjuga/Info.plist
properties: