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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.cache/
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user