Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac84b22977 | |||
| 3ee1563cb0 | |||
| d0582c4ce7 | |||
| 26ce662c60 | |||
| dd08a09860 | |||
| c794c013f0 | |||
| eec0fb56d5 | |||
| 209602eaad | |||
| cee962c0e0 | |||
| f14008f96f | |||
| 5c0fc8ee2d | |||
| d61f9e50b1 | |||
| 900a927f95 | |||
| f0eb75a28a | |||
| f4c139aed0 | |||
| 0af8e648fe | |||
| c890095610 | |||
| 164a0a1bb7 | |||
| d49eb38a6d | |||
| 0b7d4a73ad | |||
| 9aa4d0836d | |||
| 5db4b014a9 | |||
| de446b2301 | |||
| a416233a2d | |||
| 70d8299df8 | |||
| 51067e23fd | |||
| 05a367fdbe | |||
| 09e49bda2c | |||
| ade091f108 | |||
| 05a0cc0d17 | |||
| f368c24ad6 | |||
| 90aea92fba | |||
| dce2cc1f51 | |||
| 06b47d37cf | |||
| f993bfbb96 | |||
| fcb907718a | |||
| 9c7033d1b4 | |||
| 0a099c3fc9 | |||
| 57f945a4d3 | |||
| 5777a210cd | |||
| 98badc98ad | |||
| 4093b5a7f3 | |||
| 5d3accb2c0 | |||
| 3d8cbccc4e | |||
| cc6ec70ed9 | |||
| d99d88e73c | |||
| 8e1c9b6bf1 | |||
| d9ddaa4902 | |||
| cdf1e05c4c | |||
| 455df18dad | |||
| 3c5600f562 | |||
| 5f90a01314 | |||
| cd491bd695 | |||
| df96a9e540 | |||
| c73762ab9f | |||
| f809bc2a1d | |||
| 63dfc5e41a | |||
| 5ba76a947b | |||
| bb596b19bd | |||
| 47a7871c38 | |||
| b17fb49d49 | |||
| 5b69f3b630 | |||
| ff4f906128 | |||
| 23ff9d66de | |||
| b48e935231 |
+20
@@ -34,3 +34,23 @@ Pods/
|
||||
screens/
|
||||
conjugato/
|
||||
conjuu-es/
|
||||
|
||||
# Video scraping pipeline (kept locally for reruns, not committed)
|
||||
scrape/
|
||||
*.webm
|
||||
*.mp4
|
||||
*.mkv
|
||||
|
||||
# Third-party textbook sources (not redistributable)
|
||||
*.pdf
|
||||
*.epub
|
||||
epub_extract/
|
||||
|
||||
# Textbook extraction artifacts — regenerate locally via run_pipeline.sh.
|
||||
# Scripts are committed; their generated outputs are not.
|
||||
Conjuga/Scripts/textbook/*.json
|
||||
Conjuga/Scripts/textbook/review.html
|
||||
Conjuga/Scripts/textbook/paired_vocab_llm/
|
||||
# Note: the app-bundle copies (Conjuga/Conjuga/textbook_{data,vocab}.json)
|
||||
# ARE committed so `xcodebuild` works on a fresh clone without first running
|
||||
# the pipeline. They're regenerated from the scripts when content changes.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Project rules
|
||||
|
||||
## Git
|
||||
|
||||
- **Never run `git commit` or `git push` without an explicit request from the user in the current turn.** File edits are fine; committing and pushing are not. Wait to be told.
|
||||
@@ -8,59 +8,103 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */; };
|
||||
04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D232CDA43CC9218D748BA121 /* ClozeView.swift */; };
|
||||
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */; };
|
||||
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; };
|
||||
0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */; };
|
||||
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */; };
|
||||
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */; };
|
||||
13A649C50A2B72662F92F0BD /* VocabSessionQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */; };
|
||||
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; };
|
||||
362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */ = {isa = PBXBuildFile; productRef = 08D6313690BEE4E2F18EADC3 /* YouTubeKit */; };
|
||||
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; };
|
||||
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.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 */; };
|
||||
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
|
||||
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2ACC4C35491174257770941 /* VerbReviewStore.swift */; };
|
||||
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; };
|
||||
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 */; };
|
||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
|
||||
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */; };
|
||||
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
|
||||
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 */; };
|
||||
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */; };
|
||||
81E4DB9F64F3FF3AB8BCB03A /* TextbookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */; };
|
||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; };
|
||||
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; };
|
||||
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; };
|
||||
8B516215E0842189DEA0DBB1 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */; };
|
||||
8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79576893566932D2BE207528 /* ChatView.swift */; };
|
||||
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
|
||||
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */; };
|
||||
A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */; };
|
||||
A651D3E1584A34472BCE53B5 /* textbook_vocab.json in Resources */ = {isa = PBXBuildFile; fileRef = 3540936F058728CFD87B1A1E /* textbook_vocab.json */; };
|
||||
A7DF435F99E66E067F2B33E1 /* ListeningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D1904DF07E0A6816134CF3 /* ListeningView.swift */; };
|
||||
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
|
||||
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; };
|
||||
ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96C065B8787DEC6818E497 /* ConversationService.swift */; };
|
||||
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */; };
|
||||
ACE9D8B3116757B5D6F0F766 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713F23A9C2935408B136C7C7 /* StoryGenerator.swift */; };
|
||||
AE156A84B4ECDB1A4A38CE88 /* ExtraStudyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */; };
|
||||
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */; };
|
||||
B10A324C06F0957DDE2233F8 /* TextbookChapterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39908548430FDF01D76201FB /* TextbookChapterView.swift */; };
|
||||
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */; };
|
||||
B48C0015BE53279B0631C2D7 /* ChatLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648436F8326CF95777E2FA58 /* ChatLibraryView.swift */; };
|
||||
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */; };
|
||||
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; };
|
||||
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = BCCBABD74CADDB118179D8E9 /* SharedModels */; };
|
||||
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 */; };
|
||||
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */; };
|
||||
C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */; };
|
||||
C8C3880535008764B7117049 /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADCA82DDD34DF36D59BB283 /* DataLoader.swift */; };
|
||||
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 */; };
|
||||
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; };
|
||||
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4B5204F6B8647C816814F0 /* SyncToast.swift */; };
|
||||
@@ -70,30 +114,18 @@
|
||||
DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */; };
|
||||
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833516C5D57F164C8660A479 /* CourseView.swift */; };
|
||||
F15E2E02DD6261323BB75789 /* textbook_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 539736EB2AB8D149ED0F9C39 /* textbook_data.json */; };
|
||||
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */ = {isa = PBXBuildFile; fileRef = 6658C35E454C137B53FC05A4 /* youtube_videos.json */; };
|
||||
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; };
|
||||
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; };
|
||||
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */; };
|
||||
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */; };
|
||||
8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327659ABFD524514B6D2D505 /* StoryGenerator.swift */; };
|
||||
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */; };
|
||||
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8B6081226847E0A0A174BC /* StoryReaderView.swift */; };
|
||||
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */; };
|
||||
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */; };
|
||||
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3698CE7ACF148318615293E /* VocabReviewView.swift */; };
|
||||
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A649B04B8B3C49419AD9219C /* ClozeView.swift */; };
|
||||
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */; };
|
||||
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */; };
|
||||
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D535EF6988A24B47B70209A2 /* PronunciationService.swift */; };
|
||||
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B2179562E54E148C98219D /* ListeningView.swift */; };
|
||||
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10603F454E54341AA4B9931 /* ConversationService.swift */; };
|
||||
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5667AA04211A449A9150BD28 /* ChatLibraryView.swift */; };
|
||||
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */; };
|
||||
8510085D78E248D885181E80 /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */; };
|
||||
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -121,26 +153,40 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbExampleGenerator.swift; sourceTree = "<group>"; };
|
||||
0313D24F96E6A0039C34341F /* DailyLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyLog.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
18AC3C548BDB9EF8701BE64C /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
|
||||
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusMonitor.swift; sourceTree = "<group>"; };
|
||||
195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressWidget.swift; sourceTree = "<group>"; };
|
||||
1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekProgressWidget.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingRecognizer.swift; sourceTree = "<group>"; };
|
||||
39908548430FDF01D76201FB /* TextbookChapterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterView.swift; sourceTree = "<group>"; };
|
||||
3A96C065B8787DEC6818E497 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.swift; sourceTree = "<group>"; };
|
||||
3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveContainer.swift; sourceTree = "<group>"; };
|
||||
3BC3247457109FC6BF00D85B /* TenseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseInfo.swift; sourceTree = "<group>"; };
|
||||
3CC1AD23158CBABBB753FA1E /* ConjugaWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ConjugaWidget.entitlements; sourceTree = "<group>"; };
|
||||
@@ -149,69 +195,86 @@
|
||||
42ADC600530309A9B147A663 /* IrregularHighlightText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrregularHighlightText.swift; sourceTree = "<group>"; };
|
||||
43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedWidget.swift; sourceTree = "<group>"; };
|
||||
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchService.swift; sourceTree = "<group>"; };
|
||||
496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterListView.swift; sourceTree = "<group>"; };
|
||||
49E3AD244327CBF24B7A2752 /* SpeechService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechService.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadService.swift; sourceTree = "<group>"; };
|
||||
539736EB2AB8D149ED0F9C39 /* textbook_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_data.json; sourceTree = "<group>"; };
|
||||
58394296923991E56BAC2B02 /* LyricsReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsReaderView.swift; sourceTree = "<group>"; };
|
||||
5983A534E4836F30B5281ACB /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
|
||||
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = "<group>"; };
|
||||
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRSEngine.swift; sourceTree = "<group>"; };
|
||||
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekTestView.swift; sourceTree = "<group>"; };
|
||||
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; };
|
||||
626873572466403C0288090D /* QuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizType.swift; sourceTree = "<group>"; };
|
||||
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = "<group>"; };
|
||||
648436F8326CF95777E2FA58 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = "<group>"; };
|
||||
6658C35E454C137B53FC05A4 /* youtube_videos.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = youtube_videos.json; sourceTree = "<group>"; };
|
||||
69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTableView.swift; sourceTree = "<group>"; };
|
||||
713F23A9C2935408B136C7C7 /* StoryGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGenerator.swift; sourceTree = "<group>"; };
|
||||
72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewWordIntent.swift; sourceTree = "<group>"; };
|
||||
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBuilderView.swift; sourceTree = "<group>"; };
|
||||
76BE2A08EC694FF784ED5575 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = "<group>"; };
|
||||
777C696A841803D5B775B678 /* ReferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceStore.swift; sourceTree = "<group>"; };
|
||||
79576893566932D2BE207528 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||
7E6AF62A3A949630E067DC22 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; };
|
||||
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookExerciseView.swift; sourceTree = "<group>"; };
|
||||
86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoActionsView.swift; sourceTree = "<group>"; };
|
||||
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = "<group>"; };
|
||||
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; };
|
||||
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; };
|
||||
D232CDA43CC9218D748BA121 /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; };
|
||||
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; };
|
||||
F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemChangeConjugationView.swift; sourceTree = "<group>"; };
|
||||
FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadedVideosView.swift; sourceTree = "<group>"; };
|
||||
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; };
|
||||
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
|
||||
327659ABFD524514B6D2D505 /* StoryGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGenerator.swift; sourceTree = "<group>"; };
|
||||
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryLibraryView.swift; sourceTree = "<group>"; };
|
||||
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; };
|
||||
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = "<group>"; };
|
||||
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = "<group>"; };
|
||||
D3698CE7ACF148318615293E /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = "<group>"; };
|
||||
A649B04B8B3C49419AD9219C /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; };
|
||||
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; };
|
||||
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = "<group>"; };
|
||||
D535EF6988A24B47B70209A2 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = "<group>"; };
|
||||
02B2179562E54E148C98219D /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.swift; sourceTree = "<group>"; };
|
||||
E10603F454E54341AA4B9931 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.swift; sourceTree = "<group>"; };
|
||||
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = "<group>"; };
|
||||
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureReferenceView.swift; sourceTree = "<group>"; };
|
||||
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 */
|
||||
@@ -228,6 +291,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */,
|
||||
362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -243,11 +307,16 @@
|
||||
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */,
|
||||
BC273716CD14A99EFF8206CA /* course_data.json */,
|
||||
7E6AF62A3A949630E067DC22 /* Info.plist */,
|
||||
3644B5ED77F29A65877D926A /* reflexive_verbs.json */,
|
||||
539736EB2AB8D149ED0F9C39 /* textbook_data.json */,
|
||||
3540936F058728CFD87B1A1E /* textbook_vocab.json */,
|
||||
6658C35E454C137B53FC05A4 /* youtube_videos.json */,
|
||||
A6EC7C278E4287D91A0DB355 /* youtube_videos.md */,
|
||||
353C5DE41FD410FA82E3AED7 /* Models */,
|
||||
1994867BC8E985795A172854 /* Services */,
|
||||
BFC1AEBE02CE22E6474FFEA6 /* Utilities */,
|
||||
3C75490F53C34A37084FF478 /* ViewModels */,
|
||||
A81CA75762B08D35D5B7A44D /* Views */,
|
||||
EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */,
|
||||
);
|
||||
path = Conjuga;
|
||||
sourceTree = "<group>";
|
||||
@@ -255,8 +324,9 @@
|
||||
0931AEB5B728C3A03F06A1CA /* Settings */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */,
|
||||
1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */,
|
||||
BCCC95A95581458E068E0484 /* SettingsView.swift */,
|
||||
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
@@ -274,28 +344,48 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */,
|
||||
B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */,
|
||||
3A96C065B8787DEC6818E497 /* ConversationService.swift */,
|
||||
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
|
||||
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
|
||||
76BE2A08EC694FF784ED5575 /* DictionaryService.swift */,
|
||||
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
|
||||
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
|
||||
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
|
||||
E10603F454E54341AA4B9931 /* ConversationService.swift */,
|
||||
D535EF6988A24B47B70209A2 /* PronunciationService.swift */,
|
||||
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */,
|
||||
327659ABFD524514B6D2D505 /* StoryGenerator.swift */,
|
||||
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */,
|
||||
4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */,
|
||||
777C696A841803D5B775B678 /* ReferenceStore.swift */,
|
||||
940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */,
|
||||
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
|
||||
49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
|
||||
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */,
|
||||
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */,
|
||||
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */,
|
||||
713F23A9C2935408B136C7C7 /* StoryGenerator.swift */,
|
||||
4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */,
|
||||
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */,
|
||||
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */,
|
||||
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */,
|
||||
51FA0182FB27A06FFC703138 /* VideoDownloadService.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 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */,
|
||||
15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */,
|
||||
);
|
||||
name = Vocab;
|
||||
path = Vocab;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
29F9EEAED5A6969FEDEAE227 /* Onboarding */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -308,6 +398,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0313D24F96E6A0039C34341F /* DailyLog.swift */,
|
||||
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */,
|
||||
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */,
|
||||
626873572466403C0288090D /* QuizType.swift */,
|
||||
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */,
|
||||
@@ -315,8 +406,9 @@
|
||||
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
|
||||
DAFE27F29412021AEC57E728 /* TestResult.swift */,
|
||||
E536AD1180FE10576EAC884A /* UserProgress.swift */,
|
||||
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */,
|
||||
);
|
||||
A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */,
|
||||
DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -341,6 +433,16 @@
|
||||
path = ViewModels;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
43E4D263B0AF47E401A51601 /* Stories */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
15AC27B1E3D332709657F20B /* StoryLibraryView.swift */,
|
||||
A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */,
|
||||
6A48474D969CEF5F573DF09B /* StoryReaderView.swift */,
|
||||
);
|
||||
path = Stories;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4B183AB0C56BC2EC302531E7 /* ConjugaWidget */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -361,52 +463,48 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */,
|
||||
D232CDA43CC9218D748BA121 /* ClozeView.swift */,
|
||||
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */,
|
||||
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */,
|
||||
1F842EB5E566C74658D918BB /* HandwritingView.swift */,
|
||||
20D1904DF07E0A6816134CF3 /* ListeningView.swift */,
|
||||
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
|
||||
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
|
||||
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
|
||||
D3698CE7ACF148318615293E /* VocabReviewView.swift */,
|
||||
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
|
||||
10C16AA6022E4742898745CE /* TypingView.swift */,
|
||||
E8D95887B18216FCA71643D6 /* VocabReviewView.swift */,
|
||||
8FB89F19B33894DDF27C8EC2 /* Chat */,
|
||||
895E547BEFB5D0FBF676BE33 /* Lyrics */,
|
||||
8A1DED0596E04DDE9536A9A9 /* Stories */,
|
||||
DFD75E32A53845A693D98F48 /* Chat */,
|
||||
02B2179562E54E148C98219D /* ListeningView.swift */,
|
||||
A649B04B8B3C49419AD9219C /* ClozeView.swift */,
|
||||
);
|
||||
43E4D263B0AF47E401A51601 /* Stories */,
|
||||
74AC8A0D381958D2A14316C3 /* Books */,
|
||||
1ECAF79E2138DF73BB1F6403 /* Vocab */,
|
||||
);
|
||||
path = Practice;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
74AC8A0D381958D2A14316C3 /* Books */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */,
|
||||
FF3475931F1AD16054741E65 /* BookChapterListView.swift */,
|
||||
EDD4AF96186662567525F8C4 /* BookReaderView.swift */,
|
||||
1C5F851F5C2C71C293DD9938 /* BookVoicePickerSheet.swift */,
|
||||
);
|
||||
name = Books;
|
||||
path = Books;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8102F7FA5BFE6D38B2212AD3 /* Guide */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */,
|
||||
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */,
|
||||
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */,
|
||||
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */,
|
||||
);
|
||||
86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */,
|
||||
);
|
||||
path = Guide;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DFD75E32A53845A693D98F48 /* Chat */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */,
|
||||
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */,
|
||||
);
|
||||
path = Chat;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8A1DED0596E04DDE9536A9A9 /* Stories */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */,
|
||||
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */,
|
||||
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */,
|
||||
);
|
||||
path = Stories;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
895E547BEFB5D0FBF676BE33 /* Lyrics */ = {
|
||||
isa = PBXGroup;
|
||||
@@ -419,6 +517,15 @@
|
||||
path = Lyrics;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8FB89F19B33894DDF27C8EC2 /* Chat */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
648436F8326CF95777E2FA58 /* ChatLibraryView.swift */,
|
||||
79576893566932D2BE207528 /* ChatView.swift */,
|
||||
);
|
||||
path = Chat;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A591A3B6F1F13D23D68D7A9D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -457,23 +564,21 @@
|
||||
BE5A40BAC9DD6884C58A2096 /* Course */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */,
|
||||
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */,
|
||||
833516C5D57F164C8660A479 /* CourseView.swift */,
|
||||
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
|
||||
F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */,
|
||||
496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */,
|
||||
39908548430FDF01D76201FB /* TextbookChapterView.swift */,
|
||||
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */,
|
||||
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
|
||||
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
|
||||
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */,
|
||||
8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */,
|
||||
);
|
||||
path = Course;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BFC1AEBE02CE22E6474FFEA6 /* Utilities */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
path = Utilities;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F605D24E5EA11065FD18AF7E /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -511,6 +616,7 @@
|
||||
name = Conjuga;
|
||||
packageProductDependencies = (
|
||||
BCCBABD74CADDB118179D8E9 /* SharedModels */,
|
||||
08D6313690BEE4E2F18EADC3 /* YouTubeKit */,
|
||||
);
|
||||
productName = Conjuga;
|
||||
productReference = 16C1F74196C3C5628953BE3F /* Conjuga.app */;
|
||||
@@ -555,7 +661,6 @@
|
||||
};
|
||||
};
|
||||
buildConfigurationList = F011A5DA3101F6F7CA7D2D95 /* Build configuration list for PBXProject "Conjuga" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
@@ -565,9 +670,11 @@
|
||||
mainGroup = A591A3B6F1F13D23D68D7A9D;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */,
|
||||
548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = F605D24E5EA11065FD18AF7E /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
@@ -585,6 +692,12 @@
|
||||
F59655A8B8FCE6264315DD33 /* Assets.xcassets 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 */,
|
||||
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */,
|
||||
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */,
|
||||
E3D9D82E54E37F9D38103FB9 /* book_olly-vol2.json in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -597,8 +710,14 @@
|
||||
files = (
|
||||
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */,
|
||||
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */,
|
||||
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */,
|
||||
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */,
|
||||
B48C0015BE53279B0631C2D7 /* ChatLibraryView.swift in Sources */,
|
||||
8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */,
|
||||
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */,
|
||||
04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */,
|
||||
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */,
|
||||
ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */,
|
||||
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */,
|
||||
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */,
|
||||
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */,
|
||||
@@ -607,8 +726,13 @@
|
||||
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */,
|
||||
C8C3880535008764B7117049 /* DataLoader.swift in Sources */,
|
||||
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */,
|
||||
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */,
|
||||
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */,
|
||||
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */,
|
||||
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */,
|
||||
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */,
|
||||
8B516215E0842189DEA0DBB1 /* GrammarExercise.swift in Sources */,
|
||||
8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */,
|
||||
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */,
|
||||
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */,
|
||||
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */,
|
||||
@@ -616,6 +740,7 @@
|
||||
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
|
||||
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
|
||||
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
|
||||
A7DF435F99E66E067F2B33E1 /* ListeningView.swift in Sources */,
|
||||
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */,
|
||||
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */,
|
||||
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */,
|
||||
@@ -628,8 +753,10 @@
|
||||
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */,
|
||||
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */,
|
||||
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */,
|
||||
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */,
|
||||
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */,
|
||||
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */,
|
||||
A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */,
|
||||
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */,
|
||||
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */,
|
||||
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */,
|
||||
@@ -637,39 +764,50 @@
|
||||
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */,
|
||||
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */,
|
||||
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */,
|
||||
20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */,
|
||||
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */,
|
||||
ACE9D8B3116757B5D6F0F766 /* StoryGenerator.swift in Sources */,
|
||||
61328552866DE185B15011A9 /* StoryLibraryView.swift in Sources */,
|
||||
0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */,
|
||||
CC886125F8ECE72D1AAD4861 /* StoryReaderView.swift in Sources */,
|
||||
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */,
|
||||
5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */,
|
||||
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */,
|
||||
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */,
|
||||
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */,
|
||||
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */,
|
||||
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */,
|
||||
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */,
|
||||
81E4DB9F64F3FF3AB8BCB03A /* TextbookChapterListView.swift in Sources */,
|
||||
B10A324C06F0957DDE2233F8 /* TextbookChapterView.swift in Sources */,
|
||||
354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */,
|
||||
27BA7FA9356467846A07697D /* TypingView.swift in Sources */,
|
||||
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */,
|
||||
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */,
|
||||
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */,
|
||||
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */,
|
||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
|
||||
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */,
|
||||
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */,
|
||||
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
|
||||
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */,
|
||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
||||
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */,
|
||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
||||
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */,
|
||||
8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */,
|
||||
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */,
|
||||
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */,
|
||||
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */,
|
||||
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */,
|
||||
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */,
|
||||
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */,
|
||||
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */,
|
||||
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */,
|
||||
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */,
|
||||
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */,
|
||||
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */,
|
||||
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */,
|
||||
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */,
|
||||
8510085D78E248D885181E80 /* FeatureReferenceView.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;
|
||||
};
|
||||
217A29BCEDD9D44B6DD85AF6 /* Sources */ = {
|
||||
@@ -941,7 +1079,23 @@
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/alexeichhorn/YouTubeKit.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.3.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
08D6313690BEE4E2F18EADC3 /* YouTubeKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */;
|
||||
productName = YouTubeKit;
|
||||
};
|
||||
4A4D7B02884EBA9ACD93F0FD /* SharedModels */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = SharedModels;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"originHash" : "1b6ada17bf1104878f9520a6f7cb3cd84338c0da74dc3761cef075709d7df45d",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "youtubekit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/alexeichhorn/YouTubeKit.git",
|
||||
"state" : {
|
||||
"revision" : "65be95dbb1dbd749499e0638871568c823822276",
|
||||
"version" : "0.4.8"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -40,6 +40,9 @@ struct ConjugaApp: App {
|
||||
@State private var syncMonitor = SyncStatusMonitor()
|
||||
@State private var studyTimer = StudyTimerService()
|
||||
@State private var dictionary = DictionaryService()
|
||||
@State private var verbExampleCache = VerbExampleCache()
|
||||
@State private var reflexiveStore = ReflexiveVerbStore()
|
||||
@State private var youtubeVideoStore = YouTubeVideoStore()
|
||||
|
||||
let localContainer: ModelContainer
|
||||
let cloudContainer: ModelContainer
|
||||
@@ -67,14 +70,16 @@ struct ConjugaApp: App {
|
||||
let cloudConfig = ModelConfiguration(
|
||||
"cloud",
|
||||
schema: Schema([
|
||||
ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||
ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
|
||||
]),
|
||||
cloudKitDatabase: .private("iCloud.com.conjuga.app")
|
||||
)
|
||||
cloudContainer = try ModelContainer(
|
||||
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||
for: ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
|
||||
configurations: cloudConfig
|
||||
)
|
||||
|
||||
@@ -111,6 +116,9 @@ struct ConjugaApp: App {
|
||||
.environment(\.cloudModelContextProvider, { cloudContainer.mainContext })
|
||||
.environment(studyTimer)
|
||||
.environment(dictionary)
|
||||
.environment(verbExampleCache)
|
||||
.environment(reflexiveStore)
|
||||
.environment(youtubeVideoStore)
|
||||
.task {
|
||||
let needsSeed = await DataLoader.needsSeeding(container: localContainer)
|
||||
if needsSeed {
|
||||
@@ -133,6 +141,11 @@ struct ConjugaApp: App {
|
||||
localContainer: localContainer,
|
||||
cloudContainer: cloudContainer
|
||||
)
|
||||
// Reset a broken streak immediately on launch so the
|
||||
// dashboard never shows a stale number even if the user
|
||||
// hasn't navigated to it yet.
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContainer.mainContext)
|
||||
progress.validateStreakIfStale(context: cloudContainer.mainContext)
|
||||
WidgetDataService.update(
|
||||
localContainer: localContainer,
|
||||
cloudContainer: cloudContainer
|
||||
@@ -204,20 +217,16 @@ struct ConjugaApp: App {
|
||||
}
|
||||
|
||||
private static func makeLocalContainer(at url: URL) throws -> ModelContainer {
|
||||
// Built from the single shared model list so the app and the widget
|
||||
// extension always open the store with an identical schema.
|
||||
let schema = Schema(SharedStore.localSchemaModels)
|
||||
let localConfig = ModelConfiguration(
|
||||
"local",
|
||||
schema: Schema([
|
||||
Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
]),
|
||||
schema: schema,
|
||||
url: url,
|
||||
cloudKitDatabase: .none
|
||||
)
|
||||
return try ModelContainer(
|
||||
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
configurations: localConfig
|
||||
)
|
||||
return try ModelContainer(for: schema, configurations: localConfig)
|
||||
}
|
||||
|
||||
private static func localStoreIsUsable(container: ModelContainer) -> Bool {
|
||||
@@ -244,7 +253,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 = 3 // bump: SavedSong moved to cloud container
|
||||
let resetVersion = 5 // bump: Book/BookChapter added to local container
|
||||
let key = "localStoreResetVersion"
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.conjuga.app.refresh</string>
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -22,21 +26,13 @@
|
||||
<string>1</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.education</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Conjuga uses speech recognition to check your Spanish pronunciation and transcribe what you say during listening exercises.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Conjuga needs microphone access to record your voice for pronunciation practice.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.conjuga.app.refresh</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
|
||||
@@ -32,10 +32,21 @@ final class DailyLog {
|
||||
}
|
||||
}
|
||||
|
||||
static func dateString(from date: Date) -> String {
|
||||
/// Defensive formatter: explicit POSIX locale + current timezone so date
|
||||
/// strings can never drift due to locale formatting (e.g. Arabic numerals)
|
||||
/// or implicit-zone shifts. The string format is timezone-naive
|
||||
/// `yyyy-MM-dd`, which works because we only ever compare to other
|
||||
/// strings produced by this same formatter.
|
||||
private static func makeFormatter() -> DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = .current
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: date)
|
||||
return formatter
|
||||
}
|
||||
|
||||
static func dateString(from date: Date) -> String {
|
||||
makeFormatter().string(from: date)
|
||||
}
|
||||
|
||||
static func todayString() -> String {
|
||||
@@ -43,8 +54,6 @@ final class DailyLog {
|
||||
}
|
||||
|
||||
static func date(from string: String) -> Date? {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.date(from: string)
|
||||
makeFormatter().date(from: string)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
|
||||
/// Maps tense guides ↔ grammar notes for the Guide tab's cross-link chips.
|
||||
/// The forward map is the curated source; the reverse map is derived once.
|
||||
///
|
||||
/// A tense ID appears here only if at least one grammar note in
|
||||
/// `GrammarNote.allNotesIncludingGenerated` covers a concept directly tied
|
||||
/// to that tense (forms, contrast, triggers, choice). Two tenses currently
|
||||
/// have no aligned notes and don't appear: `ind_pluscuamperfecto` and
|
||||
/// `ind_preterito_anterior`.
|
||||
enum GuideCrossLinks {
|
||||
/// Tense ID → ordered grammar note IDs that go deeper on this tense.
|
||||
/// Order matters — the first chip is the most-relevant note for the
|
||||
/// tense's primary teaching point.
|
||||
static let relatedNotes: [String: [String]] = [
|
||||
"ind_presente": [
|
||||
"present-indicative-conjugation",
|
||||
"irregular-yo-verbs",
|
||||
"stem-changing-verbs",
|
||||
"estar-gerund-progressive",
|
||||
],
|
||||
"ind_preterito": [
|
||||
"preterite-vs-imperfect",
|
||||
"stem-changing-verbs",
|
||||
],
|
||||
"ind_imperfecto": [
|
||||
"preterite-vs-imperfect",
|
||||
],
|
||||
"ind_futuro": [
|
||||
"future-vs-ir-a",
|
||||
],
|
||||
"ind_perfecto": [
|
||||
"present-perfect-tense",
|
||||
],
|
||||
"ind_futuro_perfecto": [
|
||||
"future-perfect-tense",
|
||||
],
|
||||
"cond_presente": [
|
||||
"conditional-if-clauses",
|
||||
],
|
||||
"cond_perfecto": [
|
||||
"conditional-if-clauses",
|
||||
],
|
||||
"subj_presente": [
|
||||
"subjunctive-triggers",
|
||||
"irregular-yo-verbs",
|
||||
"stem-changing-verbs",
|
||||
],
|
||||
"subj_imperfecto_1": [
|
||||
"subjunctive-triggers",
|
||||
"conditional-if-clauses",
|
||||
],
|
||||
"subj_imperfecto_2": [
|
||||
"subjunctive-triggers",
|
||||
"conditional-if-clauses",
|
||||
],
|
||||
"subj_perfecto": [
|
||||
"subjunctive-triggers",
|
||||
],
|
||||
"subj_pluscuamperfecto_1": [
|
||||
"subjunctive-triggers",
|
||||
"conditional-if-clauses",
|
||||
],
|
||||
"subj_pluscuamperfecto_2": [
|
||||
"subjunctive-triggers",
|
||||
"conditional-if-clauses",
|
||||
],
|
||||
"subj_futuro": [
|
||||
"subjunctive-triggers",
|
||||
],
|
||||
"subj_futuro_perfecto": [
|
||||
"subjunctive-triggers",
|
||||
],
|
||||
"imp_afirmativo": [
|
||||
"commands-imperative",
|
||||
],
|
||||
"imp_negativo": [
|
||||
"commands-imperative",
|
||||
"subjunctive-triggers",
|
||||
],
|
||||
]
|
||||
|
||||
/// Grammar note ID → tense IDs that point at this note, ordered by the
|
||||
/// shared `TenseInfo.order` so chips appear in canonical conjugation
|
||||
/// order.
|
||||
static let relatedTenses: [String: [String]] = {
|
||||
var inverse: [String: [String]] = [:]
|
||||
for (tenseId, noteIds) in relatedNotes {
|
||||
for noteId in noteIds {
|
||||
inverse[noteId, default: []].append(tenseId)
|
||||
}
|
||||
}
|
||||
for key in inverse.keys {
|
||||
inverse[key]?.sort { lhs, rhs in
|
||||
(TenseInfo.find(lhs)?.order ?? 999) < (TenseInfo.find(rhs)?.order ?? 999)
|
||||
}
|
||||
}
|
||||
return inverse
|
||||
}()
|
||||
|
||||
static func noteIds(forTense tenseId: String) -> [String] {
|
||||
relatedNotes[tenseId] ?? []
|
||||
}
|
||||
|
||||
static func tenseIds(forNote noteId: String) -> [String] {
|
||||
relatedTenses[noteId] ?? []
|
||||
}
|
||||
}
|
||||
@@ -55,3 +55,30 @@ final class CourseReviewCard {
|
||||
self.back = back
|
||||
}
|
||||
}
|
||||
|
||||
/// SRS record for verb-level vocab practice (EN ↔ ES infinitive recall),
|
||||
/// separate from per-form `ReviewCard` so a user's vocab progress doesn't
|
||||
/// collide with conjugation form mastery.
|
||||
///
|
||||
/// CloudKit-synced; uniqueness on `id` is enforced via fetch-or-create in
|
||||
/// `VerbReviewStore` since CloudKit forbids `@Attribute(.unique)`.
|
||||
@Model
|
||||
final class VerbReviewCard {
|
||||
var id: String = ""
|
||||
var verbId: Int = 0
|
||||
|
||||
var easeFactor: Double = 2.5
|
||||
var interval: Int = 0
|
||||
var repetitions: Int = 0
|
||||
var dueDate: Date = Date()
|
||||
var lastReviewDate: Date?
|
||||
|
||||
init(verbId: Int) {
|
||||
self.id = Self.makeId(verbId: verbId)
|
||||
self.verbId = verbId
|
||||
}
|
||||
|
||||
static func makeId(verbId: Int) -> String {
|
||||
"verb-\(verbId)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ final class UserProgress {
|
||||
var selectedLevel: String = "basic"
|
||||
var showVosotros: Bool = true
|
||||
var autoFillStem: Bool = false
|
||||
var showReflexiveVerbsOnly: Bool = false
|
||||
|
||||
// Legacy CloudKit array-backed fields retained for migration compatibility.
|
||||
var enabledTenses: [String] = []
|
||||
@@ -21,6 +22,10 @@ final class UserProgress {
|
||||
var enabledTensesBlob: String = ""
|
||||
var unlockedBadgesBlob: String = ""
|
||||
|
||||
// Multi-select level + irregularity filters (Issue #26).
|
||||
var selectedLevelsBlob: String = ""
|
||||
var enabledIrregularCategoriesBlob: String = ""
|
||||
|
||||
init() {}
|
||||
|
||||
var selectedVerbLevel: VerbLevel {
|
||||
@@ -44,6 +49,44 @@ final class UserProgress {
|
||||
}
|
||||
}
|
||||
|
||||
/// Levels currently enabled for practice. Multi-select per Issue #26.
|
||||
/// Setting this also syncs `selectedLevel` to the highest-ranked selection so
|
||||
/// legacy single-level consumers (widget, AI scenarios, word-of-day) stay consistent.
|
||||
var selectedVerbLevels: Set<VerbLevel> {
|
||||
get {
|
||||
let raw = decodeStringArray(from: selectedLevelsBlob, fallback: [])
|
||||
let decoded = Set(raw.compactMap(VerbLevel.init(rawValue:)))
|
||||
if !decoded.isEmpty { return decoded }
|
||||
// Pre-migration users: treat the single selectedLevel as the set.
|
||||
if let legacy = VerbLevel(rawValue: selectedLevel) {
|
||||
return [legacy]
|
||||
}
|
||||
return []
|
||||
}
|
||||
set {
|
||||
let sorted = newValue.map(\.rawValue)
|
||||
selectedLevelsBlob = Self.encodeStringArray(sorted)
|
||||
selectedLevel = VerbLevel.highest(in: newValue)?.rawValue ?? VerbLevel.basic.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
/// The single representative level for callers that need one value
|
||||
/// (word-of-day widget, AI chat/story scenarios). Highest selected level.
|
||||
var primaryLevel: VerbLevel {
|
||||
VerbLevel.highest(in: selectedVerbLevels) ?? selectedVerbLevel
|
||||
}
|
||||
|
||||
var enabledIrregularCategories: Set<IrregularSpan.SpanCategory> {
|
||||
get {
|
||||
let raw = decodeStringArray(from: enabledIrregularCategoriesBlob, fallback: [])
|
||||
return Set(raw.compactMap(IrregularSpan.SpanCategory.init(rawValue:)))
|
||||
}
|
||||
set {
|
||||
let sorted = newValue.map(\.rawValue)
|
||||
enabledIrregularCategoriesBlob = Self.encodeStringArray(sorted)
|
||||
}
|
||||
}
|
||||
|
||||
func setTenseEnabled(_ tenseId: String, enabled: Bool) {
|
||||
var values = Set(enabledTenseIDs)
|
||||
if enabled {
|
||||
@@ -54,12 +97,50 @@ final class UserProgress {
|
||||
enabledTenseIDs = values.sorted()
|
||||
}
|
||||
|
||||
func setLevelEnabled(_ level: VerbLevel, enabled: Bool) {
|
||||
var values = selectedVerbLevels
|
||||
if enabled {
|
||||
values.insert(level)
|
||||
} else {
|
||||
values.remove(level)
|
||||
}
|
||||
selectedVerbLevels = values
|
||||
}
|
||||
|
||||
func setIrregularCategoryEnabled(_ category: IrregularSpan.SpanCategory, enabled: Bool) {
|
||||
var values = enabledIrregularCategories
|
||||
if enabled {
|
||||
values.insert(category)
|
||||
} else {
|
||||
values.remove(category)
|
||||
}
|
||||
enabledIrregularCategories = values
|
||||
}
|
||||
|
||||
func unlockBadge(_ badgeId: String) {
|
||||
var values = Set(unlockedBadgeIDs)
|
||||
values.insert(badgeId)
|
||||
unlockedBadgeIDs = values.sorted()
|
||||
}
|
||||
|
||||
/// Resets `currentStreak` to zero if more than one day has passed since
|
||||
/// the last recorded activity. Without this check the dashboard keeps
|
||||
/// displaying a stale streak number for days after the user actually
|
||||
/// stops practicing — the underlying counter only updates on the *next*
|
||||
/// practice action. Call from app launch and the dashboard's `.task`.
|
||||
@MainActor
|
||||
func validateStreakIfStale(today: Date = Date(), context: ModelContext) {
|
||||
guard !todayDate.isEmpty else { return }
|
||||
let todayString = DailyLog.dateString(from: today)
|
||||
if todayDate == todayString { return }
|
||||
guard let prevDate = DailyLog.date(from: todayDate) else { return }
|
||||
let diff = Calendar.current.dateComponents([.day], from: prevDate, to: today)
|
||||
if (diff.day ?? Int.max) > 1 && currentStreak != 0 {
|
||||
currentStreak = 0
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
|
||||
func migrateLegacyStorageIfNeeded() {
|
||||
if enabledTensesBlob.isEmpty && !enabledTenses.isEmpty {
|
||||
enabledTenseIDs = enabledTenses
|
||||
@@ -67,6 +148,9 @@ final class UserProgress {
|
||||
if unlockedBadgesBlob.isEmpty && !unlockedBadges.isEmpty {
|
||||
unlockedBadgeIDs = unlockedBadges
|
||||
}
|
||||
if selectedLevelsBlob.isEmpty, let legacy = VerbLevel(rawValue: selectedLevel) {
|
||||
selectedVerbLevels = [legacy]
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeStringArray(from blob: String, fallback: [String]) -> [String] {
|
||||
@@ -86,4 +170,5 @@ final class UserProgress {
|
||||
}
|
||||
return string
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// The user's active vocab-flashcard study set, persisted and CloudKit-synced
|
||||
/// so the same group of verbs follows them across launches and across devices.
|
||||
/// A new group is created when the previous one is fully learned.
|
||||
///
|
||||
/// CloudKit-synced; uniqueness on `id` is enforced in code (CloudKit forbids
|
||||
/// `@Attribute(.unique)`). There is one active standard group at a time.
|
||||
@Model
|
||||
final class VocabStudyGroup {
|
||||
var id: String = ""
|
||||
/// JSON-encoded `[StoredVocabEntry]` — the in-session queue (un-graduated
|
||||
/// verbs only) in order, each with its learning-step state.
|
||||
var entriesJSON: Data = Data()
|
||||
var learnedCount: Int = 0
|
||||
var createdAt: Date = Date()
|
||||
|
||||
init(entriesJSON: Data, learnedCount: Int) {
|
||||
self.id = Self.activeID
|
||||
self.entriesJSON = entriesJSON
|
||||
self.learnedCount = learnedCount
|
||||
self.createdAt = Date()
|
||||
}
|
||||
|
||||
/// Single active standard-session group.
|
||||
static let activeID = "active-standard"
|
||||
|
||||
var entries: [StoredVocabEntry] {
|
||||
(try? JSONDecoder().decode([StoredVocabEntry].self, from: entriesJSON)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// One verb's persisted spot in the study group.
|
||||
struct StoredVocabEntry: Codable {
|
||||
var verbId: Int
|
||||
/// Raw value of `VocabSessionQueue.CardState`.
|
||||
var state: String
|
||||
}
|
||||
|
||||
/// Fetch / persist / clear the active study group. Operates on the cloud
|
||||
/// context so the group syncs across devices.
|
||||
struct VocabStudyGroupStore {
|
||||
let context: ModelContext
|
||||
|
||||
/// The current active group, or nil. If duplicate records exist (two
|
||||
/// devices both created one before sync settled), the newest wins.
|
||||
func activeGroup() -> VocabStudyGroup? {
|
||||
let id = VocabStudyGroup.activeID
|
||||
let descriptor = FetchDescriptor<VocabStudyGroup>(
|
||||
predicate: #Predicate<VocabStudyGroup> { $0.id == id },
|
||||
sortBy: [SortDescriptor(\VocabStudyGroup.createdAt, order: .reverse)]
|
||||
)
|
||||
return (try? context.fetch(descriptor))?.first
|
||||
}
|
||||
|
||||
/// Write the in-progress group, creating it if needed. If duplicate records
|
||||
/// exist (two devices created a group before sync settled), the newest is
|
||||
/// updated and the rest deleted so a single record survives.
|
||||
func persist(entries: [StoredVocabEntry], learnedCount: Int) {
|
||||
let data = (try? JSONEncoder().encode(entries)) ?? Data()
|
||||
let id = VocabStudyGroup.activeID
|
||||
let descriptor = FetchDescriptor<VocabStudyGroup>(
|
||||
predicate: #Predicate<VocabStudyGroup> { $0.id == id },
|
||||
sortBy: [SortDescriptor(\VocabStudyGroup.createdAt, order: .reverse)]
|
||||
)
|
||||
let existing = (try? context.fetch(descriptor)) ?? []
|
||||
if let newest = existing.first {
|
||||
newest.entriesJSON = data
|
||||
newest.learnedCount = learnedCount
|
||||
for duplicate in existing.dropFirst() { context.delete(duplicate) }
|
||||
} else {
|
||||
context.insert(VocabStudyGroup(entriesJSON: data, learnedCount: learnedCount))
|
||||
}
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
/// Remove the active group (and any duplicates) — the set is finished.
|
||||
func clear() {
|
||||
let id = VocabStudyGroup.activeID
|
||||
let descriptor = FetchDescriptor<VocabStudyGroup>(
|
||||
predicate: #Predicate<VocabStudyGroup> { $0.id == id }
|
||||
)
|
||||
for group in (try? context.fetch(descriptor)) ?? [] {
|
||||
context.delete(group)
|
||||
}
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
|
||||
/// Thin app-side wrapper around the SharedModels `AnswerGrader`. All logic
|
||||
/// lives in SharedModels so it can be unit tested.
|
||||
enum AnswerChecker {
|
||||
static func grade(userText: String, canonical: String, alternates: [String] = []) -> TextbookGrade {
|
||||
AnswerGrader.grade(userText: userText, canonical: canonical, alternates: alternates)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Drives "read aloud" mode for `BookReaderView`. Wraps an
|
||||
/// `AVSpeechSynthesizer` with a queue of paragraph utterances and exposes the
|
||||
/// current paragraph/word index so the view can highlight the active word.
|
||||
///
|
||||
/// Skips vocabulary lines (`palabra = meaning`) since the synth pronounces the
|
||||
/// `=` awkwardly and the bilingual gloss is reference material, not prose.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
|
||||
// MARK: - Observable state
|
||||
|
||||
private(set) var isReading: Bool = false
|
||||
private(set) var isPaused: Bool = false
|
||||
private(set) var currentParagraphIndex: Int? = nil
|
||||
private(set) var currentWordIndex: Int? = nil
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
var rate: Float = 0.45
|
||||
var voiceIdentifier: String? = nil
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
/// Built on first use, not in `init`. `AVSpeechSynthesizer()` connects to
|
||||
/// the system speech daemon, so allocating one per `BookReaderView` struct
|
||||
/// construction (SwiftUI rebuilds the struct on every parent render) is a
|
||||
/// real cost — deferring it keeps controller construction cheap.
|
||||
@ObservationIgnored
|
||||
private lazy var synthesizer: AVSpeechSynthesizer = {
|
||||
let synth = AVSpeechSynthesizer()
|
||||
synth.delegate = self
|
||||
return synth
|
||||
}()
|
||||
private var queue: [QueueEntry] = []
|
||||
private var queueCursor: Int = 0
|
||||
private var audioSessionConfigured = false
|
||||
|
||||
private struct QueueEntry {
|
||||
let paragraphIndex: Int
|
||||
let text: String
|
||||
let wordRanges: [Range<String.Index>]
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Public control
|
||||
|
||||
/// Start (or restart) reading the given paragraphs. Indexes in
|
||||
/// `currentParagraphIndex` are positions in the original `paragraphs`
|
||||
/// array — vocab lines are skipped internally but the visible index space
|
||||
/// matches what the caller passed.
|
||||
func start(paragraphs: [String], from startIndex: Int = 0) {
|
||||
stop()
|
||||
configureAudioSession()
|
||||
|
||||
var entries: [QueueEntry] = []
|
||||
for (idx, p) in paragraphs.enumerated() where idx >= startIndex {
|
||||
if Self.isVocabLine(p) { continue }
|
||||
entries.append(QueueEntry(
|
||||
paragraphIndex: idx,
|
||||
text: p,
|
||||
wordRanges: Self.wordRanges(in: p)
|
||||
))
|
||||
}
|
||||
guard !entries.isEmpty else { return }
|
||||
|
||||
queue = entries
|
||||
queueCursor = 0
|
||||
isReading = true
|
||||
isPaused = false
|
||||
speakCurrent()
|
||||
}
|
||||
|
||||
/// Pause immediately (no word boundary). Use this for tap-to-define so the
|
||||
/// audio stops the moment the user taps.
|
||||
func pause() {
|
||||
guard isReading, !isPaused else { return }
|
||||
synthesizer.pauseSpeaking(at: .immediate)
|
||||
isPaused = true
|
||||
}
|
||||
|
||||
func resume() {
|
||||
guard isReading, isPaused else { return }
|
||||
synthesizer.continueSpeaking()
|
||||
isPaused = false
|
||||
}
|
||||
|
||||
func stop() {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
queue.removeAll()
|
||||
queueCursor = 0
|
||||
isReading = false
|
||||
isPaused = false
|
||||
currentParagraphIndex = nil
|
||||
currentWordIndex = nil
|
||||
deactivateAudioSession()
|
||||
}
|
||||
|
||||
// MARK: - Vocab detection + word ranges
|
||||
|
||||
/// Vocabulary entries in the book are formatted `palabra = meaning`.
|
||||
/// Reading them aloud says "palabra equals meaning" which is awkward, and
|
||||
/// they're reference material, so the read-along skips them.
|
||||
static func isVocabLine(_ paragraph: String) -> Bool {
|
||||
paragraph.contains(" = ")
|
||||
}
|
||||
|
||||
/// Word ranges that match the BookReaderView's space-split rendering —
|
||||
/// the visible word index N in a paragraph corresponds to wordRanges[N].
|
||||
static func wordRanges(in text: String) -> [Range<String.Index>] {
|
||||
var ranges: [Range<String.Index>] = []
|
||||
var i = text.startIndex
|
||||
while i < text.endIndex {
|
||||
while i < text.endIndex && text[i] == " " {
|
||||
i = text.index(after: i)
|
||||
}
|
||||
guard i < text.endIndex else { break }
|
||||
let start = i
|
||||
while i < text.endIndex && text[i] != " " {
|
||||
i = text.index(after: i)
|
||||
}
|
||||
ranges.append(start..<i)
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func speakCurrent() {
|
||||
guard queueCursor < queue.count else {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
let entry = queue[queueCursor]
|
||||
currentParagraphIndex = entry.paragraphIndex
|
||||
currentWordIndex = nil
|
||||
|
||||
let utterance = AVSpeechUtterance(string: entry.text)
|
||||
utterance.voice = resolveVoice()
|
||||
utterance.rate = rate
|
||||
utterance.pitchMultiplier = 1.0
|
||||
utterance.postUtteranceDelay = 0.20
|
||||
synthesizer.speak(utterance)
|
||||
}
|
||||
|
||||
private func resolveVoice() -> AVSpeechSynthesisVoice? {
|
||||
if let id = voiceIdentifier, let v = AVSpeechSynthesisVoice(identifier: id) {
|
||||
return v
|
||||
}
|
||||
return AVSpeechSynthesisVoice(language: "es-ES")
|
||||
}
|
||||
|
||||
private func configureAudioSession() {
|
||||
guard !audioSessionConfigured else { return }
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playback, mode: .spokenAudio, options: [])
|
||||
try session.setActive(true)
|
||||
audioSessionConfigured = true
|
||||
} catch {
|
||||
print("[BookSpeech] audio session failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Release audio focus on stop so the OS hands control back to whatever
|
||||
/// app was playing before (music, podcast, etc.). Without this the
|
||||
/// session stays "active" until the app is killed.
|
||||
private func deactivateAudioSession() {
|
||||
guard audioSessionConfigured else { return }
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
|
||||
} catch {
|
||||
print("[BookSpeech] audio session deactivation failed: \(error)")
|
||||
}
|
||||
audioSessionConfigured = false
|
||||
}
|
||||
|
||||
private func handleWillSpeakRange(_ range: NSRange) {
|
||||
guard queueCursor < queue.count else { return }
|
||||
let entry = queue[queueCursor]
|
||||
guard let stringRange = Range(range, in: entry.text) else { return }
|
||||
let lower = stringRange.lowerBound
|
||||
let idx = entry.wordRanges.firstIndex {
|
||||
$0.lowerBound <= lower && lower < $0.upperBound
|
||||
}
|
||||
if let idx, idx != currentWordIndex {
|
||||
currentWordIndex = idx
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDidFinish() {
|
||||
queueCursor += 1
|
||||
if queueCursor < queue.count {
|
||||
speakCurrent()
|
||||
} else {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVSpeechSynthesizerDelegate
|
||||
|
||||
nonisolated func speechSynthesizer(
|
||||
_ synthesizer: AVSpeechSynthesizer,
|
||||
willSpeakRangeOfSpeechString characterRange: NSRange,
|
||||
utterance: AVSpeechUtterance
|
||||
) {
|
||||
Task { @MainActor in self.handleWillSpeakRange(characterRange) }
|
||||
}
|
||||
|
||||
nonisolated func speechSynthesizer(
|
||||
_ synthesizer: AVSpeechSynthesizer,
|
||||
didFinish utterance: AVSpeechUtterance
|
||||
) {
|
||||
Task { @MainActor in self.handleDidFinish() }
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,15 @@ import SharedModels
|
||||
import Foundation
|
||||
|
||||
actor DataLoader {
|
||||
static let courseDataVersion = 5
|
||||
static let courseDataVersion = 9 // bump: all 19 tense guides + 36 grammar notes enriched to teacher-handout depth
|
||||
static let courseDataKey = "courseDataVersion"
|
||||
|
||||
static let textbookDataVersion = 14
|
||||
static let textbookDataKey = "textbookDataVersion"
|
||||
|
||||
static let bookDataVersion = 6 // bump: BookChapter.paragraphCount added
|
||||
static let bookDataKey = "bookDataVersion"
|
||||
|
||||
/// Quick check: does the DB need seeding or course data refresh?
|
||||
static func needsSeeding(container: ModelContainer) async -> Bool {
|
||||
let context = ModelContext(container)
|
||||
@@ -15,6 +21,12 @@ actor DataLoader {
|
||||
let storedVersion = UserDefaults.standard.integer(forKey: courseDataKey)
|
||||
if storedVersion < courseDataVersion { return true }
|
||||
|
||||
let textbookVersion = UserDefaults.standard.integer(forKey: textbookDataKey)
|
||||
if textbookVersion < textbookDataVersion { return true }
|
||||
|
||||
let bookVersion = UserDefaults.standard.integer(forKey: bookDataKey)
|
||||
if bookVersion < bookDataVersion { return true }
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -133,6 +145,98 @@ actor DataLoader {
|
||||
|
||||
// Seed course data (uses the same mainContext so @Query sees it)
|
||||
seedCourseData(context: context)
|
||||
|
||||
// Seed textbook data — only bump the version key if the seed
|
||||
// actually inserted rows, so a missing/unparseable bundle doesn't
|
||||
// permanently lock us out of future re-seeds.
|
||||
if seedTextbookData(context: context) {
|
||||
UserDefaults.standard.set(textbookDataVersion, forKey: textbookDataKey)
|
||||
}
|
||||
|
||||
if seedBooks(context: context) {
|
||||
UserDefaults.standard.set(bookDataVersion, forKey: bookDataKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-seed books if the version has changed or the rows are missing.
|
||||
static func refreshBooksDataIfNeeded(container: ModelContainer) async {
|
||||
let shared = UserDefaults.standard
|
||||
let context = ModelContext(container)
|
||||
let existingCount = (try? context.fetchCount(FetchDescriptor<Book>())) ?? 0
|
||||
let storedVersion = shared.integer(forKey: bookDataKey)
|
||||
let versionCurrent = storedVersion >= bookDataVersion
|
||||
|
||||
print("[DataLoader] refreshBooksDataIfNeeded: existing=\(existingCount) stored=\(storedVersion) target=\(bookDataVersion) versionCurrent=\(versionCurrent)")
|
||||
|
||||
if versionCurrent && existingCount > 0 { return }
|
||||
|
||||
if let existing = try? context.fetch(FetchDescriptor<Book>()) {
|
||||
for book in existing { context.delete(book) }
|
||||
}
|
||||
if let existing = try? context.fetch(FetchDescriptor<BookChapter>()) {
|
||||
for chapter in existing { context.delete(chapter) }
|
||||
}
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("[DataLoader] ERROR: book wipe save failed: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
if seedBooks(context: context) {
|
||||
shared.set(bookDataVersion, forKey: bookDataKey)
|
||||
print("[DataLoader] Book data re-seeded to version \(bookDataVersion)")
|
||||
} else {
|
||||
print("[DataLoader] Book reseed produced no rows — leaving version key untouched")
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-seed textbook data if the version has changed OR if the rows are
|
||||
/// missing on disk. The row-count check exists because anything opening
|
||||
/// this store with a subset schema (e.g. an out-of-date widget extension)
|
||||
/// can destructively drop the rows without touching UserDefaults — so a
|
||||
/// pure version-flag trigger would leave us permanently empty.
|
||||
static func refreshTextbookDataIfNeeded(container: ModelContainer) async {
|
||||
let shared = UserDefaults.standard
|
||||
let context = ModelContext(container)
|
||||
let existingCount = (try? context.fetchCount(FetchDescriptor<TextbookChapter>())) ?? 0
|
||||
let versionCurrent = shared.integer(forKey: textbookDataKey) >= textbookDataVersion
|
||||
|
||||
if versionCurrent && existingCount > 0 { return }
|
||||
|
||||
if versionCurrent {
|
||||
print("Textbook data version current but store has \(existingCount) chapters — re-seeding...")
|
||||
} else {
|
||||
print("Textbook data version outdated — re-seeding...")
|
||||
}
|
||||
|
||||
// Fetch + delete individually instead of batch delete. SwiftData's
|
||||
// context.delete(model:) hits the store directly and doesn't always
|
||||
// clear the unique-constraint index before the reseed's save runs,
|
||||
// so re-inserting rows with the same .unique id can throw.
|
||||
let textbookCourseName = "Complete Spanish Step-by-Step"
|
||||
if let existing = try? context.fetch(FetchDescriptor<TextbookChapter>()) {
|
||||
for chapter in existing { context.delete(chapter) }
|
||||
}
|
||||
let deckDescriptor = FetchDescriptor<CourseDeck>(
|
||||
predicate: #Predicate<CourseDeck> { $0.courseName == textbookCourseName }
|
||||
)
|
||||
if let decks = try? context.fetch(deckDescriptor) {
|
||||
for deck in decks { context.delete(deck) }
|
||||
}
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("[DataLoader] ERROR: textbook wipe save failed: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
if seedTextbookData(context: context) {
|
||||
shared.set(textbookDataVersion, forKey: textbookDataKey)
|
||||
print("Textbook data re-seeded to version \(textbookDataVersion)")
|
||||
} else {
|
||||
print("Textbook re-seed failed — leaving version key untouched so next launch retries")
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-seed course data if the version has changed (e.g. examples were added).
|
||||
@@ -145,14 +249,35 @@ actor DataLoader {
|
||||
print("Course data version outdated — re-seeding...")
|
||||
let context = ModelContext(container)
|
||||
|
||||
// Delete existing course data
|
||||
// Delete existing course data + tense guides so they can be re-seeded
|
||||
// with updated bodies from the bundled conjuga_data.json.
|
||||
try? context.delete(model: VocabCard.self)
|
||||
try? context.delete(model: CourseDeck.self)
|
||||
try? context.delete(model: TenseGuide.self)
|
||||
try? context.save()
|
||||
|
||||
// Re-seed
|
||||
// Re-seed tense guides from the bundled JSON
|
||||
if let url = Bundle.main.url(forResource: "conjuga_data", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let guides = json["tenseGuides"] as? [[String: Any]] {
|
||||
for g in guides {
|
||||
guard let tenseId = g["tenseId"] as? String,
|
||||
let title = g["title"] as? String,
|
||||
let body = g["body"] as? String else { continue }
|
||||
context.insert(TenseGuide(tenseId: tenseId, title: title, body: body))
|
||||
}
|
||||
try? context.save()
|
||||
print("Re-seeded \(guides.count) tense guides")
|
||||
}
|
||||
|
||||
// Re-seed course data
|
||||
seedCourseData(context: context)
|
||||
|
||||
// Textbook's vocab decks/cards share the same CourseDeck/VocabCard
|
||||
// entities, so they were just wiped above. Reseed them.
|
||||
seedTextbookVocabDecks(context: context, courseName: "Complete Spanish Step-by-Step")
|
||||
|
||||
shared.set(courseDataVersion, forKey: courseDataKey)
|
||||
print("Course data re-seeded to version \(courseDataVersion)")
|
||||
}
|
||||
@@ -319,4 +444,313 @@ actor DataLoader {
|
||||
context.insert(reviewCard)
|
||||
return reviewCard
|
||||
}
|
||||
|
||||
// MARK: - Textbook seeding
|
||||
|
||||
@discardableResult
|
||||
private static func seedTextbookData(context: ModelContext) -> Bool {
|
||||
let url = Bundle.main.url(forResource: "textbook_data", withExtension: "json")
|
||||
?? Bundle.main.bundleURL.appendingPathComponent("textbook_data.json")
|
||||
guard let data = try? Data(contentsOf: url) else {
|
||||
print("[DataLoader] textbook_data.json not bundled — skipping textbook seed")
|
||||
return false
|
||||
}
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
print("[DataLoader] ERROR: Could not parse textbook_data.json")
|
||||
return false
|
||||
}
|
||||
let courseName = (json["courseName"] as? String) ?? "Textbook"
|
||||
guard let chapters = json["chapters"] as? [[String: Any]] else {
|
||||
print("[DataLoader] ERROR: textbook_data.json missing chapters")
|
||||
return false
|
||||
}
|
||||
|
||||
var inserted = 0
|
||||
for ch in chapters {
|
||||
guard let id = ch["id"] as? String,
|
||||
let number = ch["number"] as? Int,
|
||||
let title = ch["title"] as? String,
|
||||
let blocksRaw = ch["blocks"] as? [[String: Any]] else { continue }
|
||||
|
||||
let part = (ch["part"] as? Int) ?? 0
|
||||
|
||||
// Normalize each block to canonical keys expected by TextbookBlock decoder.
|
||||
var normalized: [[String: Any]] = []
|
||||
var exerciseCount = 0
|
||||
var vocabTableCount = 0
|
||||
for (i, b) in blocksRaw.enumerated() {
|
||||
var out: [String: Any] = [:]
|
||||
out["index"] = i
|
||||
let kind = (b["kind"] as? String) ?? ""
|
||||
out["kind"] = kind
|
||||
switch kind {
|
||||
case "heading":
|
||||
if let level = b["level"] { out["level"] = level }
|
||||
if let text = b["text"] { out["text"] = text }
|
||||
case "paragraph":
|
||||
if let text = b["text"] { out["text"] = text }
|
||||
case "key_vocab_header":
|
||||
break
|
||||
case "vocab_table":
|
||||
vocabTableCount += 1
|
||||
if let src = b["sourceImage"] { out["sourceImage"] = src }
|
||||
if let lines = b["ocrLines"] { out["ocrLines"] = lines }
|
||||
if let conf = b["ocrConfidence"] { out["ocrConfidence"] = conf }
|
||||
// Paired Spanish→English cards from the bounding-box extractor.
|
||||
if let cards = b["cards"] as? [[String: Any]], !cards.isEmpty {
|
||||
let normalized: [[String: Any]] = cards.compactMap { c in
|
||||
guard let front = c["front"] as? String,
|
||||
let back = c["back"] as? String else { return nil }
|
||||
return ["front": front, "back": back]
|
||||
}
|
||||
if !normalized.isEmpty {
|
||||
out["cards"] = normalized
|
||||
}
|
||||
}
|
||||
case "exercise":
|
||||
exerciseCount += 1
|
||||
if let exId = b["id"] { out["exerciseId"] = exId }
|
||||
if let inst = b["instruction"] { out["instruction"] = inst }
|
||||
if let extra = b["extra"] { out["extra"] = extra }
|
||||
if let prompts = b["prompts"] { out["prompts"] = prompts }
|
||||
if let items = b["answerItems"] { out["answerItems"] = items }
|
||||
if let freeform = b["freeform"] { out["freeform"] = freeform }
|
||||
default:
|
||||
break
|
||||
}
|
||||
normalized.append(out)
|
||||
}
|
||||
|
||||
let bodyJSON: Data
|
||||
do {
|
||||
bodyJSON = try JSONSerialization.data(withJSONObject: normalized, options: [])
|
||||
} catch {
|
||||
print("[DataLoader] failed to encode chapter \(number) blocks: \(error)")
|
||||
continue
|
||||
}
|
||||
|
||||
let chapter = TextbookChapter(
|
||||
id: id,
|
||||
number: number,
|
||||
title: title,
|
||||
part: part,
|
||||
courseName: courseName,
|
||||
bodyJSON: bodyJSON,
|
||||
exerciseCount: exerciseCount,
|
||||
vocabTableCount: vocabTableCount
|
||||
)
|
||||
context.insert(chapter)
|
||||
inserted += 1
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("[DataLoader] ERROR: textbook chapter save failed: \(error)")
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify rows actually hit the store — guards against the case where
|
||||
// save returned cleanly but no rows were persisted.
|
||||
let persisted = (try? context.fetchCount(FetchDescriptor<TextbookChapter>())) ?? 0
|
||||
guard persisted > 0 else {
|
||||
print("[DataLoader] ERROR: textbook seeded \(inserted) chapters but persisted count is 0")
|
||||
return false
|
||||
}
|
||||
|
||||
// Seed textbook-derived vocabulary flashcards as CourseDecks so the
|
||||
// existing Course UI can surface them alongside LanGo decks.
|
||||
seedTextbookVocabDecks(context: context, courseName: courseName)
|
||||
|
||||
print("Textbook seeding complete: \(inserted) chapters inserted, \(persisted) persisted")
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Books seeding
|
||||
|
||||
/// Walk the bundle for any `book_*.json` resources and seed `Book` +
|
||||
/// `BookChapter` rows from each one. Returns true when at least one row
|
||||
/// was inserted (mirrors `seedTextbookData`'s contract).
|
||||
@discardableResult
|
||||
private static func seedBooks(context: ModelContext) -> Bool {
|
||||
let bookURLs = bundledBookJSONURLs()
|
||||
guard !bookURLs.isEmpty else {
|
||||
print("[DataLoader] no book_*.json bundled — skipping book seed")
|
||||
return false
|
||||
}
|
||||
|
||||
var insertedBooks = 0
|
||||
for url in bookURLs {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
print("[DataLoader] WARN: could not read \(url.lastPathComponent)")
|
||||
continue
|
||||
}
|
||||
guard let slug = json["slug"] as? String,
|
||||
let title = json["title"] as? String,
|
||||
let chaptersRaw = json["chapters"] as? [[String: Any]] else {
|
||||
print("[DataLoader] WARN: \(url.lastPathComponent) missing required fields")
|
||||
continue
|
||||
}
|
||||
let author = (json["author"] as? String) ?? ""
|
||||
let language = (json["language"] as? String) ?? "es"
|
||||
|
||||
// Pre-computed per-book glossary, keyed by cleaned word.
|
||||
var glossary: [String: WordGloss] = [:]
|
||||
if let glossaryRaw = json["glossary"] as? [String: [String: String]] {
|
||||
for (word, fields) in glossaryRaw {
|
||||
glossary[word] = WordGloss(
|
||||
baseForm: fields["baseForm"] ?? word,
|
||||
english: fields["english"] ?? "",
|
||||
partOfSpeech: fields["partOfSpeech"] ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
let glossaryData = (try? JSONEncoder().encode(glossary)) ?? Data()
|
||||
|
||||
let book = Book(
|
||||
slug: slug,
|
||||
title: title,
|
||||
author: author,
|
||||
language: language,
|
||||
chapterCount: chaptersRaw.count,
|
||||
accentColorHex: accentHex(forSlug: slug),
|
||||
glossaryJSON: glossaryData
|
||||
)
|
||||
context.insert(book)
|
||||
insertedBooks += 1
|
||||
|
||||
for ch in chaptersRaw {
|
||||
guard let number = ch["number"] as? Int,
|
||||
let chTitle = ch["title"] as? String else { continue }
|
||||
let paragraphsES = (ch["paragraphsES"] as? [String]) ?? []
|
||||
let paragraphsEN = (ch["paragraphsEN"] as? [String]) ?? []
|
||||
let esData = (try? JSONEncoder().encode(paragraphsES)) ?? Data()
|
||||
let enData = (try? JSONEncoder().encode(paragraphsEN)) ?? Data()
|
||||
let chapter = BookChapter(
|
||||
id: "\(slug)-ch\(number)",
|
||||
bookSlug: slug,
|
||||
number: number,
|
||||
title: chTitle,
|
||||
paragraphCount: paragraphsES.count,
|
||||
paragraphsESJSON: esData,
|
||||
paragraphsENJSON: enData
|
||||
)
|
||||
context.insert(chapter)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("[DataLoader] ERROR: book save failed: \(error)")
|
||||
return false
|
||||
}
|
||||
|
||||
let persistedBooks = (try? context.fetchCount(FetchDescriptor<Book>())) ?? 0
|
||||
let persistedChapters = (try? context.fetchCount(FetchDescriptor<BookChapter>())) ?? 0
|
||||
guard persistedBooks > 0 else {
|
||||
print("[DataLoader] ERROR: seeded \(insertedBooks) books but persisted count is 0")
|
||||
return false
|
||||
}
|
||||
print("Book seeding complete: \(persistedBooks) books, \(persistedChapters) chapters")
|
||||
return true
|
||||
}
|
||||
|
||||
/// Slugs of books bundled with the app. Kept explicit so device installs
|
||||
/// don't depend on `Bundle.urls(forResourcesWithExtension:subdirectory:)`
|
||||
/// successfully enumerating the bundle — that API has been observed to
|
||||
/// return empty for some iOS configurations even when the resource is
|
||||
/// present, matching the same `bundleURL.appendingPathComponent` fallback
|
||||
/// used by the textbook seed.
|
||||
private static let bundledBookSlugs: [String] = [
|
||||
"olly-vol2",
|
||||
]
|
||||
|
||||
/// Resolve URLs for every bundled book. Uses the explicit-slug fast path
|
||||
/// first (mirrors `seedTextbookData`'s lookup pattern), then falls back to
|
||||
/// directory enumeration so newly-bundled books are picked up without a
|
||||
/// code change.
|
||||
private static func bundledBookJSONURLs() -> [URL] {
|
||||
var seen = Set<String>()
|
||||
var out: [URL] = []
|
||||
let bundle = Bundle.main
|
||||
|
||||
for slug in bundledBookSlugs {
|
||||
let filename = "book_\(slug).json"
|
||||
let url = bundle.url(forResource: "book_\(slug)", withExtension: "json")
|
||||
?? bundle.bundleURL.appendingPathComponent(filename)
|
||||
if FileManager.default.fileExists(atPath: url.path),
|
||||
seen.insert(filename).inserted {
|
||||
out.append(url)
|
||||
}
|
||||
}
|
||||
|
||||
if let urls = bundle.urls(forResourcesWithExtension: "json", subdirectory: nil) {
|
||||
for url in urls where url.lastPathComponent.hasPrefix("book_") {
|
||||
if seen.insert(url.lastPathComponent).inserted {
|
||||
out.append(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let names = out.map(\.lastPathComponent).joined(separator: ", ")
|
||||
print("[DataLoader] bundledBookJSONURLs found \(out.count) files: [\(names)]")
|
||||
return out.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
||||
}
|
||||
|
||||
/// Deterministic accent colour for a book, derived from its slug so the
|
||||
/// cover tile has a stable colour across launches.
|
||||
private static func accentHex(forSlug slug: String) -> String {
|
||||
let palette = [
|
||||
"#7B6CF6", "#E07A5F", "#3D5A80", "#81B29A",
|
||||
"#F2CC8F", "#D4A5A5", "#5B8A72", "#A06CD5",
|
||||
]
|
||||
let hash = slug.unicodeScalars.reduce(0) { ($0 &* 31) &+ Int($1.value) }
|
||||
return palette[abs(hash) % palette.count]
|
||||
}
|
||||
|
||||
private static func seedTextbookVocabDecks(context: ModelContext, courseName: String) {
|
||||
let url = Bundle.main.url(forResource: "textbook_vocab", withExtension: "json")
|
||||
?? Bundle.main.bundleURL.appendingPathComponent("textbook_vocab.json")
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let chaptersArr = json["chapters"] as? [[String: Any]]
|
||||
else { return }
|
||||
|
||||
let courseSlug = courseName.lowercased()
|
||||
.replacingOccurrences(of: " ", with: "-")
|
||||
|
||||
var deckCount = 0
|
||||
var cardCount = 0
|
||||
for chData in chaptersArr {
|
||||
guard let chNum = chData["chapter"] as? Int,
|
||||
let cards = chData["cards"] as? [[String: Any]],
|
||||
!cards.isEmpty else { continue }
|
||||
|
||||
let deckId = "textbook_\(courseSlug)_ch\(chNum)"
|
||||
let title = "Chapter \(chNum) vocabulary"
|
||||
let deck = CourseDeck(
|
||||
id: deckId,
|
||||
weekNumber: chNum,
|
||||
title: title,
|
||||
cardCount: cards.count,
|
||||
courseName: courseName,
|
||||
isReversed: false
|
||||
)
|
||||
context.insert(deck)
|
||||
deckCount += 1
|
||||
|
||||
for c in cards {
|
||||
guard let front = c["front"] as? String,
|
||||
let back = c["back"] as? String else { continue }
|
||||
let card = VocabCard(front: front, back: back, deckId: deckId)
|
||||
card.deck = deck
|
||||
context.insert(card)
|
||||
cardCount += 1
|
||||
}
|
||||
}
|
||||
try? context.save()
|
||||
print("Textbook vocab seeding complete: \(deckCount) decks, \(cardCount) cards")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Cloud-context CRUD for `ExtraStudyMark`. Uniqueness is enforced in code via
|
||||
/// fetch-or-create on `id` (CloudKit forbids `@Attribute(.unique)`).
|
||||
struct ExtraStudyStore {
|
||||
let context: ModelContext
|
||||
|
||||
private func fetchMark(id: String) -> ExtraStudyMark? {
|
||||
let descriptor = FetchDescriptor<ExtraStudyMark>(
|
||||
predicate: #Predicate<ExtraStudyMark> { $0.id == id }
|
||||
)
|
||||
return (try? context.fetch(descriptor))?.first
|
||||
}
|
||||
|
||||
func contains(card: VocabCard) -> Bool {
|
||||
fetchMark(id: CourseCardStore.reviewKey(for: card)) != nil
|
||||
}
|
||||
|
||||
/// Toggle a mark for the given card. Returns the new "is marked" state.
|
||||
@discardableResult
|
||||
func toggle(
|
||||
card: VocabCard,
|
||||
courseName: String,
|
||||
weekNumber: Int
|
||||
) -> Bool {
|
||||
let id = CourseCardStore.reviewKey(for: card)
|
||||
if let existing = fetchMark(id: id) {
|
||||
context.delete(existing)
|
||||
try? context.save()
|
||||
return false
|
||||
}
|
||||
let mark = ExtraStudyMark(
|
||||
id: id,
|
||||
deckId: card.deckId,
|
||||
courseName: courseName,
|
||||
weekNumber: weekNumber,
|
||||
front: card.front,
|
||||
back: card.back
|
||||
)
|
||||
context.insert(mark)
|
||||
try? context.save()
|
||||
return true
|
||||
}
|
||||
|
||||
func count(courseName: String, weekNumber: Int) -> Int {
|
||||
let descriptor = FetchDescriptor<ExtraStudyMark>(
|
||||
predicate: #Predicate<ExtraStudyMark> {
|
||||
$0.courseName == courseName && $0.weekNumber == weekNumber
|
||||
}
|
||||
)
|
||||
return (try? context.fetchCount(descriptor)) ?? 0
|
||||
}
|
||||
|
||||
func countsByWeek(courseName: String) -> [Int: Int] {
|
||||
let descriptor = FetchDescriptor<ExtraStudyMark>(
|
||||
predicate: #Predicate<ExtraStudyMark> { $0.courseName == courseName }
|
||||
)
|
||||
let marks = (try? context.fetch(descriptor)) ?? []
|
||||
var counts: [Int: Int] = [:]
|
||||
for mark in marks {
|
||||
counts[mark.weekNumber, default: 0] += 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func fetch(courseName: String, weekNumber: Int) -> [ExtraStudyMark] {
|
||||
let descriptor = FetchDescriptor<ExtraStudyMark>(
|
||||
predicate: #Predicate<ExtraStudyMark> {
|
||||
$0.courseName == courseName && $0.weekNumber == weekNumber
|
||||
},
|
||||
sortBy: [SortDescriptor(\.markedAt)]
|
||||
)
|
||||
return (try? context.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
func fetchIds(courseName: String, weekNumber: Int) -> Set<String> {
|
||||
Set(fetch(courseName: courseName, weekNumber: weekNumber).map(\.id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight context passed to `VocabFlashcardView` so the in-session star
|
||||
/// button knows which week/course to attribute a mark to.
|
||||
struct ExtraStudyMarkContext: Equatable {
|
||||
let courseName: String
|
||||
let weekNumber: Int
|
||||
}
|
||||
@@ -4,14 +4,23 @@ import SwiftData
|
||||
|
||||
struct PracticeSettings: Sendable {
|
||||
let selectedLevel: String
|
||||
let selectedLevels: Set<String>
|
||||
let enabledTenses: Set<String>
|
||||
let enabledIrregularCategories: Set<IrregularSpan.SpanCategory>
|
||||
let showVosotros: Bool
|
||||
let showReflexiveVerbsOnly: Bool
|
||||
let reflexiveBaseInfinitives: Set<String>
|
||||
|
||||
init(progress: UserProgress?) {
|
||||
let resolved = progress?.enabledTenseIDs ?? []
|
||||
init(progress: UserProgress?, reflexiveBaseInfinitives: Set<String> = []) {
|
||||
let resolvedTenses = progress?.enabledTenseIDs ?? []
|
||||
let resolvedLevels = progress?.selectedVerbLevels ?? []
|
||||
self.selectedLevel = progress?.selectedLevel ?? VerbLevel.basic.rawValue
|
||||
self.enabledTenses = Set(resolved)
|
||||
self.selectedLevels = Set(resolvedLevels.map(\.rawValue))
|
||||
self.enabledTenses = Set(resolvedTenses)
|
||||
self.enabledIrregularCategories = progress?.enabledIrregularCategories ?? []
|
||||
self.showVosotros = progress?.showVosotros ?? true
|
||||
self.showReflexiveVerbsOnly = progress?.showReflexiveVerbsOnly ?? false
|
||||
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
|
||||
}
|
||||
|
||||
var selectionTenseIDs: [String] {
|
||||
@@ -36,16 +45,25 @@ struct FullTablePrompt {
|
||||
struct PracticeSessionService {
|
||||
let localContext: ModelContext
|
||||
let cloudContext: ModelContext
|
||||
let reflexiveBaseInfinitives: Set<String>
|
||||
private let referenceStore: ReferenceStore
|
||||
|
||||
init(localContext: ModelContext, cloudContext: ModelContext) {
|
||||
init(
|
||||
localContext: ModelContext,
|
||||
cloudContext: ModelContext,
|
||||
reflexiveBaseInfinitives: Set<String> = []
|
||||
) {
|
||||
self.localContext = localContext
|
||||
self.cloudContext = cloudContext
|
||||
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
|
||||
self.referenceStore = ReferenceStore(context: localContext)
|
||||
}
|
||||
|
||||
func settings() -> PracticeSettings {
|
||||
PracticeSettings(progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext))
|
||||
PracticeSettings(
|
||||
progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext),
|
||||
reflexiveBaseInfinitives: reflexiveBaseInfinitives
|
||||
)
|
||||
}
|
||||
|
||||
func nextCard(for focusMode: FocusMode, excludingVerbId lastVerbId: Int? = nil) -> PracticeCardLoad? {
|
||||
@@ -77,24 +95,114 @@ struct PracticeSessionService {
|
||||
return nil
|
||||
}
|
||||
|
||||
func randomFullTablePrompt() -> FullTablePrompt? {
|
||||
/// Builds a Full Table prompt. `previousTenseId` / `previousEnding` describe
|
||||
/// the prompt just shown; when possible the next prompt avoids repeating the
|
||||
/// same tense back-to-back and switches the verb's ending family
|
||||
/// (-ar ⇄ -er/-ir) so consecutive rounds feel varied. Both are best-effort:
|
||||
/// if honouring them would leave no eligible combo, the constraint is
|
||||
/// dropped rather than dead-ending.
|
||||
func randomFullTablePrompt(
|
||||
previousTenseId: String? = nil,
|
||||
previousEnding: String? = nil
|
||||
) -> FullTablePrompt? {
|
||||
let settings = settings()
|
||||
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
|
||||
// Full Table is testing the user's grasp of regular conjugation patterns,
|
||||
// not vocabulary recognition. Level filter is intentionally bypassed so
|
||||
// we draw from the entire verb pool — being able to conjugate `hablar`
|
||||
// regularly transfers to any other regular verb regardless of "level".
|
||||
// Irregular-category and tense filters still apply via downstream checks.
|
||||
let verbs = applyReflexiveFilter(
|
||||
to: referenceStore.fetchVerbs(),
|
||||
settings: settings
|
||||
)
|
||||
guard !verbs.isEmpty else { return nil }
|
||||
|
||||
for _ in 0..<40 {
|
||||
guard let verb = verbs.randomElement(),
|
||||
let tenseId = settings.selectionTenseIDs.randomElement(),
|
||||
let tenseInfo = TenseInfo.find(tenseId) else { continue }
|
||||
let candidateTenseIds = settings.selectionTenseIDs
|
||||
guard !candidateTenseIds.isEmpty else { return nil }
|
||||
|
||||
let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId)
|
||||
if forms.isEmpty { continue }
|
||||
return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: forms)
|
||||
let tenseChoices = tenseChoicesAvoidingRepeat(candidateTenseIds, previous: previousTenseId)
|
||||
let verbChoices = verbChoicesSwitchingFamily(verbs, previousEnding: previousEnding)
|
||||
let isConstrained = tenseChoices.count != candidateTenseIds.count
|
||||
|| verbChoices.count != verbs.count
|
||||
|
||||
// Best-effort: random-sample the constrained pool first so consecutive
|
||||
// prompts vary the tense and the -ar/-er-ir family.
|
||||
if isConstrained,
|
||||
let prompt = sampleFullTablePrompt(verbs: verbChoices, tenseIds: tenseChoices) {
|
||||
return prompt
|
||||
}
|
||||
|
||||
// No constraint, or honouring it found nothing quickly — sample the
|
||||
// full pool.
|
||||
if let prompt = sampleFullTablePrompt(verbs: verbs, tenseIds: candidateTenseIds) {
|
||||
return prompt
|
||||
}
|
||||
|
||||
// Guarantee: if any eligible (verb, tense) combo exists in the data we
|
||||
// return one. Only return nil when the user's settings genuinely produce
|
||||
// an empty pool (so the UI can show an error state instead of a blank).
|
||||
for verb in verbs.shuffled() {
|
||||
for tenseId in candidateTenseIds.shuffled() {
|
||||
guard let tenseInfo = TenseInfo.find(tenseId) else { continue }
|
||||
if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) {
|
||||
return prompt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Random-sample up to 40 (verb, tense) pairs for a fully-regular combo.
|
||||
/// With ~1750 verbs and several hundred eligible combos this almost always
|
||||
/// succeeds within a handful of attempts.
|
||||
private func sampleFullTablePrompt(verbs: [Verb], tenseIds: [String]) -> FullTablePrompt? {
|
||||
guard !verbs.isEmpty, !tenseIds.isEmpty else { return nil }
|
||||
for _ in 0..<40 {
|
||||
guard let verb = verbs.randomElement(),
|
||||
let tenseId = tenseIds.randomElement(),
|
||||
let tenseInfo = TenseInfo.find(tenseId) else { continue }
|
||||
if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) {
|
||||
return prompt
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Tense ids with the previous one removed — only when more than one tense
|
||||
/// is selected and removing it still leaves a choice.
|
||||
private func tenseChoicesAvoidingRepeat(_ ids: [String], previous: String?) -> [String] {
|
||||
guard ids.count > 1, let previous else { return ids }
|
||||
let filtered = ids.filter { $0 != previous }
|
||||
return filtered.isEmpty ? ids : filtered
|
||||
}
|
||||
|
||||
/// Verbs whose ending family (-ar vs -er/-ir) differs from the previous
|
||||
/// verb's — only when both families are actually present in the pool.
|
||||
private func verbChoicesSwitchingFamily(_ verbs: [Verb], previousEnding: String?) -> [Verb] {
|
||||
guard let previousEnding else { return verbs }
|
||||
let previousIsAr = (previousEnding == "ar")
|
||||
let switched = verbs.filter { ($0.ending == "ar") != previousIsAr }
|
||||
return switched.isEmpty ? verbs : switched
|
||||
}
|
||||
|
||||
/// Returns a `FullTablePrompt` if this verb's forms in the given tense
|
||||
/// follow the regular pattern (per `FullTableEligibility`). Nil otherwise.
|
||||
private func makePromptIfFullyRegular(
|
||||
verb: Verb,
|
||||
tenseId: String,
|
||||
tenseInfo: TenseInfo
|
||||
) -> FullTablePrompt? {
|
||||
let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId)
|
||||
guard !forms.isEmpty else { return nil }
|
||||
// Forms must arrive in personIndex order so the regularity array lines
|
||||
// up. `fetchForms` already sorts them, but assert for safety.
|
||||
let sorted = forms.sorted { $0.personIndex < $1.personIndex }
|
||||
let regularities = sorted.map { $0.regularity }
|
||||
guard FullTableEligibility.isFullyRegular(regularities: regularities) else { return nil }
|
||||
return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: sorted)
|
||||
}
|
||||
|
||||
func rate(verbId: Int, tenseId: String, personIndex: Int, quality: ReviewQuality) -> [Badge] {
|
||||
ReviewStore.recordReview(
|
||||
verbId: verbId,
|
||||
@@ -131,6 +239,27 @@ struct PracticeSessionService {
|
||||
return buildCardLoad(verb: verb, form: form)
|
||||
}
|
||||
|
||||
/// When the user has "Reflexive verbs only" enabled, restrict the allowed
|
||||
/// verb-id set to IDs whose infinitive is in the curated list.
|
||||
/// No-op otherwise.
|
||||
private func applyReflexiveFilter(to ids: Set<Int>, settings: PracticeSettings) -> Set<Int> {
|
||||
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
|
||||
return ids
|
||||
}
|
||||
let matching = ids.filter { id in
|
||||
guard let verb = referenceStore.fetchVerb(id: id) else { return false }
|
||||
return settings.reflexiveBaseInfinitives.contains(verb.infinitive.lowercased())
|
||||
}
|
||||
return matching
|
||||
}
|
||||
|
||||
private func applyReflexiveFilter(to verbs: [Verb], settings: PracticeSettings) -> [Verb] {
|
||||
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
|
||||
return verbs
|
||||
}
|
||||
return verbs.filter { settings.reflexiveBaseInfinitives.contains($0.infinitive.lowercased()) }
|
||||
}
|
||||
|
||||
private func buildCardLoad(verb: Verb, form: VerbForm) -> PracticeCardLoad {
|
||||
let spans = referenceStore.fetchSpans(
|
||||
verbId: form.verbId,
|
||||
@@ -152,7 +281,13 @@ struct PracticeSessionService {
|
||||
|
||||
private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? {
|
||||
let settings = settings()
|
||||
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
|
||||
let allowedVerbIds = applyReflexiveFilter(
|
||||
to: referenceStore.allowedVerbIDs(
|
||||
selectedLevels: settings.selectedLevels,
|
||||
irregularCategories: settings.enabledIrregularCategories
|
||||
),
|
||||
settings: settings
|
||||
)
|
||||
let now = Date()
|
||||
var descriptor = FetchDescriptor<ReviewCard>(
|
||||
predicate: #Predicate<ReviewCard> { $0.dueDate <= now },
|
||||
@@ -179,7 +314,13 @@ struct PracticeSessionService {
|
||||
|
||||
private func pickWeakForm() -> VerbForm? {
|
||||
let settings = settings()
|
||||
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
|
||||
let allowedVerbIds = applyReflexiveFilter(
|
||||
to: referenceStore.allowedVerbIDs(
|
||||
selectedLevels: settings.selectedLevels,
|
||||
irregularCategories: settings.enabledIrregularCategories
|
||||
),
|
||||
settings: settings
|
||||
)
|
||||
|
||||
let descriptor = FetchDescriptor<ReviewCard>(
|
||||
predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 },
|
||||
@@ -201,7 +342,15 @@ struct PracticeSessionService {
|
||||
|
||||
private func pickIrregularForm(filter: IrregularityFilter) -> VerbForm? {
|
||||
let settings = settings()
|
||||
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
|
||||
// Focus mode explicitly selects one irregular category, so the user's
|
||||
// settings-level irregular filter is deliberately skipped here.
|
||||
let allowedVerbIds = applyReflexiveFilter(
|
||||
to: referenceStore.allowedVerbIDs(
|
||||
selectedLevels: settings.selectedLevels,
|
||||
irregularCategories: []
|
||||
),
|
||||
settings: settings
|
||||
)
|
||||
let typeRange: ClosedRange<Int>
|
||||
|
||||
switch filter {
|
||||
@@ -238,7 +387,13 @@ struct PracticeSessionService {
|
||||
private func pickCommonTenseForm() -> VerbForm? {
|
||||
let settings = settings()
|
||||
let coreTenseIDs = TenseID.coreTenseIDs
|
||||
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
|
||||
let verbs = applyReflexiveFilter(
|
||||
to: referenceStore.fetchVerbs(
|
||||
selectedLevels: settings.selectedLevels,
|
||||
irregularCategories: settings.enabledIrregularCategories
|
||||
),
|
||||
settings: settings
|
||||
)
|
||||
guard let verb = verbs.randomElement() else { return nil }
|
||||
|
||||
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
|
||||
@@ -251,7 +406,13 @@ struct PracticeSessionService {
|
||||
|
||||
private func pickRandomForm() -> VerbForm? {
|
||||
let settings = settings()
|
||||
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
|
||||
let verbs = applyReflexiveFilter(
|
||||
to: referenceStore.fetchVerbs(
|
||||
selectedLevels: settings.selectedLevels,
|
||||
irregularCategories: settings.enabledIrregularCategories
|
||||
),
|
||||
settings: settings
|
||||
)
|
||||
guard let verb = verbs.randomElement() else { return nil }
|
||||
|
||||
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
|
||||
|
||||
@@ -65,27 +65,26 @@ final class PronunciationService {
|
||||
|
||||
do {
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
try audioSession.setCategory(.playAndRecord, mode: .measurement, options: [.duckOthers, .defaultToSpeaker])
|
||||
try audioSession.setCategory(.record, mode: .measurement, options: [.duckOthers])
|
||||
try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
|
||||
audioEngine = AVAudioEngine()
|
||||
// Use SFSpeechAudioBufferRecognitionRequest with the recognizer
|
||||
// directly — avoid AVAudioEngine entirely since it produces
|
||||
// zero-length buffers on some devices causing assertion crashes.
|
||||
request = SFSpeechAudioBufferRecognitionRequest()
|
||||
|
||||
guard let audioEngine, let request else { return }
|
||||
guard let request else { return }
|
||||
request.shouldReportPartialResults = true
|
||||
request.requiresOnDeviceRecognition = recognizer.supportsOnDeviceRecognition
|
||||
|
||||
// Use AVAudioEngine with the native input format
|
||||
audioEngine = AVAudioEngine()
|
||||
guard let audioEngine else { return }
|
||||
|
||||
let inputNode = audioEngine.inputNode
|
||||
let recordingFormat = inputNode.outputFormat(forBus: 0)
|
||||
|
||||
// Validate format — 0 channels crashes installTap
|
||||
guard recordingFormat.channelCount > 0 else {
|
||||
print("[PronunciationService] invalid recording format (0 channels)")
|
||||
self.audioEngine = nil
|
||||
self.request = nil
|
||||
return
|
||||
}
|
||||
|
||||
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in
|
||||
// Use nil format — lets the system pick a compatible format
|
||||
// and avoids the mDataByteSize(0) assertion from format mismatches
|
||||
inputNode.installTap(onBus: 0, bufferSize: 4096, format: nil) { buffer, _ in
|
||||
request.append(buffer)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,50 @@ struct ReferenceStore {
|
||||
Set(fetchVerbs(selectedLevel: selectedLevel).map(\.id))
|
||||
}
|
||||
|
||||
/// Union of data-levels for all selected user-facing levels.
|
||||
/// Empty input produces an empty result — callers decide how to handle that.
|
||||
func fetchVerbs(selectedLevels: Set<String>) -> [Verb] {
|
||||
guard !selectedLevels.isEmpty else { return [] }
|
||||
let ids = PracticeFilter.verbIDs(
|
||||
matchingLevels: selectedLevels,
|
||||
in: fetchVerbs().map { .init(id: $0.id, level: $0.level) }
|
||||
)
|
||||
return fetchVerbs().filter { ids.contains($0.id) }
|
||||
}
|
||||
|
||||
/// Practice verb pool intersecting selected levels with selected irregular-span categories.
|
||||
/// Delegates to `PracticeFilter` so the intersection logic is unit-tested
|
||||
/// in SharedModels without a ModelContainer (Issue #26).
|
||||
func allowedVerbIDs(
|
||||
selectedLevels: Set<String>,
|
||||
irregularCategories: Set<IrregularSpan.SpanCategory>
|
||||
) -> Set<Int> {
|
||||
PracticeFilter.allowedVerbIDs(
|
||||
verbs: fetchVerbs().map { .init(id: $0.id, level: $0.level) },
|
||||
spans: allIrregularSlots(),
|
||||
selectedLevels: selectedLevels,
|
||||
irregularCategories: irregularCategories
|
||||
)
|
||||
}
|
||||
|
||||
/// Convenience: full Verb objects passing both filters.
|
||||
func fetchVerbs(
|
||||
selectedLevels: Set<String>,
|
||||
irregularCategories: Set<IrregularSpan.SpanCategory>
|
||||
) -> [Verb] {
|
||||
let ids = allowedVerbIDs(
|
||||
selectedLevels: selectedLevels,
|
||||
irregularCategories: irregularCategories
|
||||
)
|
||||
return fetchVerbs().filter { ids.contains($0.id) }
|
||||
}
|
||||
|
||||
private func allIrregularSlots() -> [PracticeFilter.IrregularSlot] {
|
||||
let descriptor = FetchDescriptor<IrregularSpan>()
|
||||
let spans = (try? context.fetch(descriptor)) ?? []
|
||||
return spans.map { .init(verbId: $0.verbId, category: $0.category) }
|
||||
}
|
||||
|
||||
func fetchVerb(id: Int) -> Verb? {
|
||||
let descriptor = FetchDescriptor<Verb>(predicate: #Predicate<Verb> { $0.id == id })
|
||||
return (try? context.fetch(descriptor))?.first
|
||||
@@ -50,6 +94,19 @@ struct ReferenceStore {
|
||||
return (try? context.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Map of tenseId → conjugated forms for a verb, used to ground and
|
||||
/// validate LLM-generated example sentences.
|
||||
func conjugatedForms(verbId: Int, tenseIds: [String]) -> [String: [String]] {
|
||||
var map: [String: [String]] = [:]
|
||||
for tenseId in tenseIds {
|
||||
let forms = fetchForms(verbId: verbId, tenseId: tenseId)
|
||||
.map(\.form)
|
||||
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
if !forms.isEmpty { map[tenseId] = forms }
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
func fetchForm(verbId: Int, tenseId: String, personIndex: Int) -> VerbForm? {
|
||||
let descriptor = FetchDescriptor<VerbForm>(
|
||||
predicate: #Predicate<VerbForm> { form in
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
|
||||
/// Loads and queries the curated reflexive-verb list bundled with the app
|
||||
/// (Gitea issue #28). One JSON load at init; in-memory lookup thereafter.
|
||||
///
|
||||
/// `entries(for:)` returns a list because a single base infinitive may map to
|
||||
/// multiple reflexive entries — e.g., `ponerse` covers both "to put on
|
||||
/// (clothing) / to become" and "to come to an agreement (with)".
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ReflexiveVerbStore {
|
||||
|
||||
/// Process-wide accessor for services that can't use @Environment injection
|
||||
/// (e.g. PracticeSessionService called from ViewModels). Views should still
|
||||
/// prefer @Environment(ReflexiveVerbStore.self) for consistency.
|
||||
static let shared = ReflexiveVerbStore()
|
||||
|
||||
private(set) var entries: [ReflexiveVerb] = []
|
||||
private var indexByBase: [String: [ReflexiveVerb]] = [:]
|
||||
|
||||
/// Set of base infinitives present in the list. Cheap lookup for filters.
|
||||
private(set) var baseInfinitives: Set<String> = []
|
||||
|
||||
init(bundle: Bundle = .main) {
|
||||
load(from: bundle)
|
||||
}
|
||||
|
||||
/// All reflexive entries whose base infinitive matches (case-insensitive).
|
||||
func entries(for baseInfinitive: String) -> [ReflexiveVerb] {
|
||||
indexByBase[baseInfinitive.lowercased()] ?? []
|
||||
}
|
||||
|
||||
/// Convenience — true when the verb's bare infinitive appears in the list.
|
||||
func isReflexive(baseInfinitive: String) -> Bool {
|
||||
baseInfinitives.contains(baseInfinitive.lowercased())
|
||||
}
|
||||
|
||||
private func load(from bundle: Bundle) {
|
||||
guard let url = bundle.url(forResource: "reflexive_verbs", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url) else {
|
||||
print("[ReflexiveVerbStore] bundled reflexive_verbs.json not found")
|
||||
return
|
||||
}
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode([ReflexiveVerb].self, from: data)
|
||||
entries = decoded
|
||||
var index: [String: [ReflexiveVerb]] = [:]
|
||||
for entry in decoded {
|
||||
index[entry.baseInfinitive.lowercased(), default: []].append(entry)
|
||||
}
|
||||
indexByBase = index
|
||||
baseInfinitives = Set(index.keys)
|
||||
print("[ReflexiveVerbStore] loaded \(decoded.count) entries (\(baseInfinitives.count) distinct base infinitives)")
|
||||
} catch {
|
||||
print("[ReflexiveVerbStore] decode failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,13 +72,13 @@ struct ReviewStore {
|
||||
return newCard
|
||||
}
|
||||
|
||||
/// Bumps the streak / "showed up today" bookkeeping without touching
|
||||
/// review-specific counters. Call from any user-initiated learning action
|
||||
/// — sending a chat message, doing an exercise, watching a curated video,
|
||||
/// looking up a word in lyrics, etc. Safe to call multiple times per day;
|
||||
/// only the first call on a fresh date moves the streak.
|
||||
@discardableResult
|
||||
static func updateProgress(
|
||||
reviewIncrement: Int,
|
||||
correctIncrement: Int,
|
||||
context: ModelContext,
|
||||
date: Date = Date()
|
||||
) -> UserProgress {
|
||||
static func recordActivity(context: ModelContext, date: Date = Date()) -> UserProgress {
|
||||
let progress = fetchOrCreateUserProgress(context: context)
|
||||
let todayString = DailyLog.dateString(from: date)
|
||||
|
||||
@@ -97,9 +97,25 @@ struct ReviewStore {
|
||||
progress.todayCount = 0
|
||||
}
|
||||
|
||||
progress.longestStreak = max(progress.longestStreak, progress.currentStreak)
|
||||
try? context.save()
|
||||
return progress
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func updateProgress(
|
||||
reviewIncrement: Int,
|
||||
correctIncrement: Int,
|
||||
context: ModelContext,
|
||||
date: Date = Date()
|
||||
) -> UserProgress {
|
||||
// Bump streak / today-date first so review-specific counters land on
|
||||
// the correct day if this is the user's first action after midnight.
|
||||
let progress = recordActivity(context: context, date: date)
|
||||
let todayString = DailyLog.dateString(from: date)
|
||||
|
||||
progress.todayCount += reviewIncrement
|
||||
progress.totalReviewed += reviewIncrement
|
||||
progress.longestStreak = max(progress.longestStreak, progress.currentStreak)
|
||||
|
||||
let log = fetchOrCreateDailyLog(dateString: todayString, context: context)
|
||||
log.reviewCount += reviewIncrement
|
||||
|
||||
@@ -9,6 +9,8 @@ enum StartupCoordinator {
|
||||
static func bootstrap(localContainer: ModelContainer) async {
|
||||
await DataLoader.seedIfNeeded(container: localContainer)
|
||||
await DataLoader.refreshCourseDataIfNeeded(container: localContainer)
|
||||
await DataLoader.refreshTextbookDataIfNeeded(container: localContainer)
|
||||
await DataLoader.refreshBooksDataIfNeeded(container: localContainer)
|
||||
}
|
||||
|
||||
/// Recurring maintenance: legacy migrations, identity repair, cloud dedup.
|
||||
|
||||
@@ -26,12 +26,14 @@ enum StoreInspector {
|
||||
let hasZVERBFORM = tables.contains("ZVERBFORM")
|
||||
let hasZTENSEGUIDE = tables.contains("ZTENSEGUIDE")
|
||||
let hasZVOCABCARD = tables.contains("ZVOCABCARD")
|
||||
let hasZTEXTBOOKCHAPTER = tables.contains("ZTEXTBOOKCHAPTER")
|
||||
|
||||
var summary = "[StoreInspector:\(label)] \(tables.count) tables"
|
||||
summary += " | ZVERB=\(hasZVERB ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERB") : -1)"
|
||||
summary += " ZVERBFORM=\(hasZVERBFORM ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERBFORM") : -1)"
|
||||
summary += " ZTENSEGUIDE=\(hasZTENSEGUIDE ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZTENSEGUIDE") : -1)"
|
||||
summary += " ZVOCABCARD=\(hasZVOCABCARD ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVOCABCARD") : -1)"
|
||||
summary += " ZTEXTBOOKCHAPTER=\(hasZTEXTBOOKCHAPTER ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZTEXTBOOKCHAPTER") : -1)"
|
||||
print(summary)
|
||||
|
||||
// Log all Z-tables (SwiftData entity tables start with Z, minus Core Data system tables)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
|
||||
/// Disk-backed cache for verb example sentences (Issue #27). One JSON file
|
||||
/// in the Caches directory keyed by verb id; lazy-loaded on first access and
|
||||
/// write-through on every generation. Matches DictionaryService's disk pattern.
|
||||
///
|
||||
/// Cache eviction by the OS is acceptable because contents are regenerable.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class VerbExampleCache {
|
||||
|
||||
/// Bump to invalidate every cached example set. Raised to 2 for Issue #33
|
||||
/// — examples generated before the verb-grounding/validation fix could
|
||||
/// contain sentences built on the wrong verb.
|
||||
private static let cacheVersion = 2
|
||||
|
||||
private struct CacheFile: Codable {
|
||||
var version: Int
|
||||
var entries: [String: [VerbExample]]
|
||||
}
|
||||
|
||||
private var store: [Int: [VerbExample]] = [:]
|
||||
private var isLoaded = false
|
||||
|
||||
init() {}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Look up cached examples for a verb; returns nil on miss.
|
||||
/// Safe to call before `loadIfNeeded()`; it triggers the disk load itself.
|
||||
func examples(for verbId: Int) -> [VerbExample]? {
|
||||
loadIfNeeded()
|
||||
return store[verbId]
|
||||
}
|
||||
|
||||
/// Store newly generated examples and persist to disk.
|
||||
func setExamples(_ examples: [VerbExample], for verbId: Int) {
|
||||
loadIfNeeded()
|
||||
store[verbId] = examples
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Disk I/O
|
||||
|
||||
private static var cacheURL: URL {
|
||||
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("verb_examples.json")
|
||||
}
|
||||
|
||||
private func loadIfNeeded() {
|
||||
guard !isLoaded else { return }
|
||||
defer { isLoaded = true }
|
||||
|
||||
guard let data = try? Data(contentsOf: Self.cacheURL),
|
||||
let decoded = try? JSONDecoder().decode(CacheFile.self, from: data),
|
||||
decoded.version == Self.cacheVersion
|
||||
else {
|
||||
// Missing, unreadable, old flat format, or stale version — start
|
||||
// fresh so pre-fix examples don't linger.
|
||||
return
|
||||
}
|
||||
|
||||
// Persisted with String keys because JSON object keys are strings;
|
||||
// convert back to Int for in-memory lookup.
|
||||
var rebuilt: [Int: [VerbExample]] = [:]
|
||||
for (key, value) in decoded.entries {
|
||||
if let id = Int(key) {
|
||||
rebuilt[id] = value
|
||||
}
|
||||
}
|
||||
store = rebuilt
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let entries = Dictionary(uniqueKeysWithValues: store.map { (String($0.key), $0.value) })
|
||||
let file = CacheFile(version: Self.cacheVersion, entries: entries)
|
||||
guard let data = try? JSONEncoder().encode(file) else { return }
|
||||
try? data.write(to: Self.cacheURL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import Foundation
|
||||
import FoundationModels
|
||||
import SharedModels
|
||||
|
||||
/// Generates a set of example sentences for a single verb, one per core tense
|
||||
/// (Issue #27). Mirrors the StoryGenerator pattern: @Generable response types,
|
||||
/// a static availability flag, and a single generate(...) entry point.
|
||||
///
|
||||
/// Issue #33: the model used to drift onto other verbs partway through the
|
||||
/// 6-example batch (a "tener" set would contain sentences built on estar / ir
|
||||
/// / deber). Two defenses now apply:
|
||||
/// 1. The prompt embeds the verb's *exact* conjugated forms per tense, so
|
||||
/// the model echoes a real form instead of recalling one.
|
||||
/// 2. Every generated sentence is validated against those forms; failures
|
||||
/// are regenerated once, and anything still wrong is dropped rather than
|
||||
/// shown.
|
||||
@MainActor
|
||||
struct VerbExampleGenerator {
|
||||
|
||||
// MARK: - Generable Types
|
||||
|
||||
@Generable
|
||||
struct GeneratedExampleSet {
|
||||
@Guide(
|
||||
description: "Six example sentences, one per tense in the exact order requested. Each sentence must actually use the target verb conjugated in that tense.",
|
||||
.count(6)
|
||||
)
|
||||
var examples: [GeneratedExample]
|
||||
}
|
||||
|
||||
@Generable
|
||||
struct GeneratedExample {
|
||||
@Guide(description: "The tense id this sentence demonstrates. Must match one of the ids provided in the prompt exactly (e.g. ind_presente).")
|
||||
var tenseId: String
|
||||
|
||||
@Guide(description: "A natural Spanish sentence, 6-14 words, that uses the target verb in the specified tense. For imperative tenses use tú or nosotros — never yo.")
|
||||
var spanish: String
|
||||
|
||||
@Guide(description: "An accurate, idiomatic English translation of the Spanish sentence.")
|
||||
var english: String
|
||||
}
|
||||
|
||||
// MARK: - Generation
|
||||
|
||||
/// Generate one validated example per tense in `tenseIds`.
|
||||
///
|
||||
/// - Parameter formsByTense: the verb's conjugated forms keyed by tenseId
|
||||
/// (from `ReferenceStore.conjugatedForms`). Used to ground the prompt
|
||||
/// and validate the output. A tense with no forms here is accepted
|
||||
/// without validation.
|
||||
static func generate(
|
||||
verbInfinitive: String,
|
||||
verbEnglish: String,
|
||||
tenseIds: [String],
|
||||
formsByTense: [String: [String]]
|
||||
) async throws -> [VerbExample] {
|
||||
// A thrown error here (model busy, context overflow) shouldn't abort the
|
||||
// whole generation — treat it as a fully-failed batch so the retry below
|
||||
// still runs, same as the retry path's own `try?`.
|
||||
let firstPass = (try? await generateBatch(
|
||||
verbInfinitive: verbInfinitive,
|
||||
verbEnglish: verbEnglish,
|
||||
tenseIds: tenseIds,
|
||||
formsByTense: formsByTense
|
||||
)) ?? [:]
|
||||
|
||||
var valid: [String: VerbExample] = [:]
|
||||
var failedTenses: [String] = []
|
||||
for id in tenseIds {
|
||||
if let ex = firstPass[id], exampleUsesVerb(ex.spanish, forms: formsByTense[id] ?? []) {
|
||||
valid[id] = ex
|
||||
} else {
|
||||
failedTenses.append(id)
|
||||
}
|
||||
}
|
||||
|
||||
// One focused retry — regenerate the whole batch, but only adopt the
|
||||
// results for tenses that failed the first pass.
|
||||
if !failedTenses.isEmpty {
|
||||
let retry = try? await generateBatch(
|
||||
verbInfinitive: verbInfinitive,
|
||||
verbEnglish: verbEnglish,
|
||||
tenseIds: tenseIds,
|
||||
formsByTense: formsByTense
|
||||
)
|
||||
if let retry {
|
||||
for id in failedTenses {
|
||||
if let ex = retry[id], exampleUsesVerb(ex.spanish, forms: formsByTense[id] ?? []) {
|
||||
valid[id] = ex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Requested order, dropping any tense that never produced a valid
|
||||
// sentence — better to show fewer examples than wrong ones.
|
||||
return tenseIds.compactMap { valid[$0] }
|
||||
}
|
||||
|
||||
// MARK: - Single batch call
|
||||
|
||||
private static func generateBatch(
|
||||
verbInfinitive: String,
|
||||
verbEnglish: String,
|
||||
tenseIds: [String],
|
||||
formsByTense: [String: [String]]
|
||||
) async throws -> [String: VerbExample] {
|
||||
let tenseBlock = tenseIds.compactMap { id -> String? in
|
||||
guard let info = TenseInfo.find(id) else { return nil }
|
||||
let forms = formsByTense[id] ?? []
|
||||
if forms.isEmpty {
|
||||
return "- \(id) (\(info.english))"
|
||||
}
|
||||
return "- \(id) (\(info.english)): use one of these exact conjugated forms — \(forms.joined(separator: ", "))"
|
||||
}.joined(separator: "\n")
|
||||
|
||||
let session = LanguageModelSession(instructions: """
|
||||
You are a Spanish language teacher writing short example sentences for a learner.
|
||||
The learner is studying the verb "\(verbInfinitive)" (to \(verbEnglish)).
|
||||
EVERY sentence must use "\(verbInfinitive)" as its main verb, conjugated in the
|
||||
requested tense — never substitute a different verb (no estar, ir, deber, etc.
|
||||
unless the target verb itself is that verb). Each sentence must:
|
||||
- Contain one of the exact conjugated forms listed for its tense.
|
||||
- Be 6-14 words, natural and everyday.
|
||||
- Use vocabulary appropriate for intermediate learners.
|
||||
- Vary subjects and contexts across the set; do not reuse the same subject twice.
|
||||
For imperative tenses, address "tú" or "nosotros" — never "yo".
|
||||
""")
|
||||
|
||||
let prompt = """
|
||||
Write one example sentence for "\(verbInfinitive)" per tense below, in this order:
|
||||
\(tenseBlock)
|
||||
|
||||
Return one GeneratedExample per tense with the matching tenseId, spanish, and english.
|
||||
The Spanish sentence MUST contain one of the conjugated forms shown for that tense.
|
||||
"""
|
||||
|
||||
let response = try await session.respond(to: prompt, generating: GeneratedExampleSet.self)
|
||||
|
||||
// `uniquingKeysWith` defensively — the schema forces 6 examples even
|
||||
// when fewer tenses are requested, so the model may repeat a tenseId.
|
||||
return Dictionary(
|
||||
response.content.examples.map {
|
||||
($0.tenseId, VerbExample(tenseId: $0.tenseId, spanish: $0.spanish, english: $0.english))
|
||||
},
|
||||
uniquingKeysWith: { first, _ in first }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
/// True when `sentence` contains at least one of `forms` as a whole word
|
||||
/// (accent- and case-insensitive). Empty `forms` → accept (can't validate).
|
||||
static func exampleUsesVerb(_ sentence: String, forms: [String]) -> Bool {
|
||||
guard !forms.isEmpty else { return true }
|
||||
let sentenceWords = foldedWords(sentence)
|
||||
let formWords = Set(forms.flatMap { foldedWords($0) })
|
||||
return !sentenceWords.isDisjoint(with: formWords)
|
||||
}
|
||||
|
||||
private static func foldedWords(_ text: String) -> Set<String> {
|
||||
let folded = text.folding(
|
||||
options: [.diacriticInsensitive, .caseInsensitive],
|
||||
locale: nil
|
||||
)
|
||||
return Set(folded.split { !$0.isLetter }.map(String.init))
|
||||
}
|
||||
|
||||
static var isAvailable: Bool {
|
||||
SystemLanguageModel.default.availability == .available
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// SRS rating for verb-level vocab practice. Mirrors `CourseReviewStore` but
|
||||
/// keyed by `verbId` (the integer primary key on `Verb`).
|
||||
struct VerbReviewStore {
|
||||
let context: ModelContext
|
||||
|
||||
@discardableResult
|
||||
func fetchOrCreateReviewCard(verbId: Int) -> VerbReviewCard {
|
||||
let id = VerbReviewCard.makeId(verbId: verbId)
|
||||
let descriptor = FetchDescriptor<VerbReviewCard>(
|
||||
predicate: #Predicate<VerbReviewCard> { $0.id == id }
|
||||
)
|
||||
if let existing = (try? context.fetch(descriptor))?.first {
|
||||
return existing
|
||||
}
|
||||
let card = VerbReviewCard(verbId: verbId)
|
||||
context.insert(card)
|
||||
return card
|
||||
}
|
||||
|
||||
func rate(verbId: Int, quality: ReviewQuality) {
|
||||
let card = fetchOrCreateReviewCard(verbId: verbId)
|
||||
let result = SRSEngine.review(
|
||||
quality: quality,
|
||||
currentEase: card.easeFactor,
|
||||
currentInterval: card.interval,
|
||||
currentReps: card.repetitions
|
||||
)
|
||||
card.easeFactor = result.easeFactor
|
||||
card.interval = result.interval
|
||||
card.repetitions = result.repetitions
|
||||
card.dueDate = SRSEngine.nextDueDate(interval: result.interval)
|
||||
card.lastReviewDate = Date()
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
import AVFoundation
|
||||
import YouTubeKit
|
||||
|
||||
/// Downloads YouTube videos for offline viewing (Issue #21, updated for #30).
|
||||
///
|
||||
/// Two-path strategy since YouTube phased out high-quality progressive streams:
|
||||
/// 1. **Progressive** — single MP4 with audio+video combined (rare, ≤360p).
|
||||
/// Download directly to the final path.
|
||||
/// 2. **Adaptive** — DASH: separate video + audio tracks. Download each to
|
||||
/// temp files, then mux with AVAssetExportSession (`.passthrough` preset,
|
||||
/// no re-encoding) into the final MP4.
|
||||
///
|
||||
/// YouTubeKit returns stream URLs only; combining tracks is the app's job.
|
||||
/// See the library's README Example 3 for the progressive-only pattern.
|
||||
///
|
||||
/// **Known fragility**: YouTubeKit scrapes YouTube's private stream API and
|
||||
/// will break when YouTube changes their internal format. Streaming (iframe
|
||||
/// embed elsewhere) keeps working regardless.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class VideoDownloadService {
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
/// Per-download state surfaced to the UI. `progress == nil` means show an
|
||||
/// indeterminate spinner (e.g. muxing phase).
|
||||
struct DownloadStatus: Equatable, Sendable {
|
||||
var progress: Double?
|
||||
var label: String
|
||||
}
|
||||
|
||||
enum DownloadError: Error, LocalizedError {
|
||||
case extractionFailed(String)
|
||||
case noSuitableStream
|
||||
case downloadFailed(String)
|
||||
case muxFailed(String)
|
||||
case fileWriteFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .extractionFailed(let why): "Could not extract video: \(why)"
|
||||
case .noSuitableStream: "No downloadable video+audio streams found for this video."
|
||||
case .downloadFailed(let why): "Download failed: \(why)"
|
||||
case .muxFailed(let why): "Could not combine audio and video: \(why)"
|
||||
case .fileWriteFailed(let why): "Could not save video: \(why)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In-flight downloads by videoId, with a phase label and progress.
|
||||
var activeDownloads: [String: DownloadStatus] = [:]
|
||||
|
||||
static let shared = VideoDownloadService()
|
||||
|
||||
// MARK: - Paths
|
||||
|
||||
private static var videosDirectory: URL {
|
||||
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
return docs.appendingPathComponent("videos", isDirectory: true)
|
||||
}
|
||||
|
||||
private static var tempDirectory: URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
}
|
||||
|
||||
private static func ensureDirectory() throws {
|
||||
let url = videosDirectory
|
||||
if !FileManager.default.fileExists(atPath: url.path) {
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
}
|
||||
}
|
||||
|
||||
static func fileURL(for videoId: String) -> URL {
|
||||
videosDirectory.appendingPathComponent("\(videoId).mp4")
|
||||
}
|
||||
|
||||
private static func tempURL(videoId: String, kind: String, ext: String) -> URL {
|
||||
tempDirectory.appendingPathComponent("\(videoId).\(kind).\(ext)")
|
||||
}
|
||||
|
||||
/// True if a downloaded MP4 exists for this videoId.
|
||||
static func isDownloaded(videoId: String) -> Bool {
|
||||
FileManager.default.fileExists(atPath: fileURL(for: videoId).path)
|
||||
}
|
||||
|
||||
// MARK: - Public download entry point
|
||||
|
||||
func download(
|
||||
videoId: String,
|
||||
title: String,
|
||||
into modelContext: ModelContext
|
||||
) async throws {
|
||||
guard activeDownloads[videoId] == nil else { return }
|
||||
activeDownloads[videoId] = DownloadStatus(progress: 0, label: "Preparing…")
|
||||
|
||||
defer { activeDownloads.removeValue(forKey: videoId) }
|
||||
|
||||
try Self.ensureDirectory()
|
||||
|
||||
let destURL = Self.fileURL(for: videoId)
|
||||
|
||||
// Resolve streams once; decide progressive vs adaptive.
|
||||
let plan: DownloadPlan
|
||||
do {
|
||||
plan = try await Self.resolvePlan(videoId: videoId)
|
||||
} catch let e as DownloadError {
|
||||
throw e
|
||||
} catch {
|
||||
throw DownloadError.extractionFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
switch plan {
|
||||
case .progressive(let url):
|
||||
activeDownloads[videoId] = DownloadStatus(progress: 0, label: "Downloading…")
|
||||
try await downloadStream(from: url, to: destURL) { [weak self] p in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.activeDownloads[videoId] = DownloadStatus(
|
||||
progress: p,
|
||||
label: "Downloading \(Int(p * 100))%"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
case .adaptive(let videoURL, let audioURL):
|
||||
let tempVideo = Self.tempURL(videoId: videoId, kind: "video", ext: "mp4")
|
||||
let tempAudio = Self.tempURL(videoId: videoId, kind: "audio", ext: "m4a")
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: tempVideo)
|
||||
try? FileManager.default.removeItem(at: tempAudio)
|
||||
}
|
||||
|
||||
// Phase 1 of 3: video track (0–55% of overall progress)
|
||||
try await downloadStream(from: videoURL, to: tempVideo) { [weak self] p in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.activeDownloads[videoId] = DownloadStatus(
|
||||
progress: p * 0.55,
|
||||
label: "Video \(Int(p * 100))%"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2 of 3: audio track (55–80%)
|
||||
try await downloadStream(from: audioURL, to: tempAudio) { [weak self] p in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.activeDownloads[videoId] = DownloadStatus(
|
||||
progress: 0.55 + p * 0.25,
|
||||
label: "Audio \(Int(p * 100))%"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3 of 3: mux (indeterminate)
|
||||
activeDownloads[videoId] = DownloadStatus(progress: nil, label: "Finalizing…")
|
||||
do {
|
||||
try await Self.mux(videoURL: tempVideo, audioURL: tempAudio, to: destURL)
|
||||
} catch let e as DownloadError {
|
||||
throw e
|
||||
} catch {
|
||||
throw DownloadError.muxFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// Record in SwiftData.
|
||||
let attrs = try? FileManager.default.attributesOfItem(atPath: destURL.path)
|
||||
let byteCount = (attrs?[.size] as? Int) ?? 0
|
||||
let entry = DownloadedVideo(
|
||||
videoId: videoId,
|
||||
title: title,
|
||||
filename: "\(videoId).mp4",
|
||||
byteCount: byteCount
|
||||
)
|
||||
modelContext.insert(entry)
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
/// Deletes the downloaded file and its SwiftData row.
|
||||
func delete(videoId: String, modelContext: ModelContext) {
|
||||
let url = Self.fileURL(for: videoId)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
|
||||
let descriptor = FetchDescriptor<DownloadedVideo>(
|
||||
predicate: #Predicate<DownloadedVideo> { $0.videoId == videoId }
|
||||
)
|
||||
if let existing = try? modelContext.fetch(descriptor) {
|
||||
for entry in existing {
|
||||
modelContext.delete(entry)
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
|
||||
/// Total bytes used by all downloads.
|
||||
static func totalBytesUsed() -> Int {
|
||||
let url = videosDirectory
|
||||
guard let contents = try? FileManager.default.contentsOfDirectory(
|
||||
at: url, includingPropertiesForKeys: [.fileSizeKey]
|
||||
) else { return 0 }
|
||||
return contents.reduce(0) { acc, file in
|
||||
let size = (try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
|
||||
return acc + size
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stream resolution
|
||||
|
||||
private enum DownloadPlan {
|
||||
case progressive(URL)
|
||||
case adaptive(video: URL, audio: URL)
|
||||
}
|
||||
|
||||
/// Picks progressive MP4 if available (single download); otherwise the
|
||||
/// best adaptive MP4 video + M4A audio pair for later muxing.
|
||||
nonisolated private static func resolvePlan(videoId: String) async throws -> DownloadPlan {
|
||||
let youtube = YouTube(videoID: videoId)
|
||||
let streams = try await youtube.streams
|
||||
|
||||
// 1. Progressive path — uses the library's own recommended filter chain.
|
||||
if let progressive = streams
|
||||
.filterVideoAndAudio()
|
||||
.filter({ $0.fileExtension == .mp4 && $0.isNativelyPlayable })
|
||||
.highestResolutionStream() {
|
||||
return .progressive(progressive.url)
|
||||
}
|
||||
|
||||
// 2. Adaptive path — highest-resolution MP4 video track + highest-bitrate M4A audio.
|
||||
let videoStream = streams
|
||||
.filterVideoOnly()
|
||||
.filter { $0.fileExtension == .mp4 && $0.isNativelyPlayable }
|
||||
.highestResolutionStream()
|
||||
let audioStream = streams
|
||||
.filterAudioOnly()
|
||||
.filter {
|
||||
($0.fileExtension == .m4a || $0.fileExtension == .mp4) && $0.isNativelyPlayable
|
||||
}
|
||||
.highestAudioBitrateStream()
|
||||
|
||||
if let v = videoStream, let a = audioStream {
|
||||
return .adaptive(video: v.url, audio: a.url)
|
||||
}
|
||||
|
||||
throw DownloadError.noSuitableStream
|
||||
}
|
||||
|
||||
// MARK: - Download helper
|
||||
|
||||
nonisolated private func downloadStream(
|
||||
from url: URL,
|
||||
to dest: URL,
|
||||
onProgress: @escaping @Sendable (Double) -> Void
|
||||
) async throws {
|
||||
let delegate = DownloadProgressDelegate(onProgress: onProgress)
|
||||
do {
|
||||
let (tempURL, _) = try await URLSession.shared.download(
|
||||
for: URLRequest(url: url),
|
||||
delegate: delegate
|
||||
)
|
||||
if FileManager.default.fileExists(atPath: dest.path) {
|
||||
try FileManager.default.removeItem(at: dest)
|
||||
}
|
||||
try FileManager.default.moveItem(at: tempURL, to: dest)
|
||||
} catch {
|
||||
throw DownloadError.downloadFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Muxing
|
||||
|
||||
/// Combines a video-only file and an audio-only file into a single MP4.
|
||||
/// Uses `.passthrough` preset — no re-encoding, just container rewrite.
|
||||
/// Falls back to `.highestQuality` (which re-encodes) if passthrough
|
||||
/// rejects the composition due to codec incompatibility.
|
||||
nonisolated private static func mux(
|
||||
videoURL: URL,
|
||||
audioURL: URL,
|
||||
to outputURL: URL
|
||||
) async throws {
|
||||
let videoAsset = AVURLAsset(url: videoURL)
|
||||
let audioAsset = AVURLAsset(url: audioURL)
|
||||
|
||||
let videoTracks = try await videoAsset.loadTracks(withMediaType: .video)
|
||||
let audioTracks = try await audioAsset.loadTracks(withMediaType: .audio)
|
||||
|
||||
guard let srcVideo = videoTracks.first,
|
||||
let srcAudio = audioTracks.first else {
|
||||
throw DownloadError.muxFailed("Downloaded streams missing expected tracks")
|
||||
}
|
||||
|
||||
let composition = AVMutableComposition()
|
||||
guard let compVideo = composition.addMutableTrack(
|
||||
withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid
|
||||
),
|
||||
let compAudio = composition.addMutableTrack(
|
||||
withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid
|
||||
) else {
|
||||
throw DownloadError.muxFailed("Could not create composition tracks")
|
||||
}
|
||||
|
||||
let videoDuration = try await videoAsset.load(.duration)
|
||||
let audioDuration = try await audioAsset.load(.duration)
|
||||
let duration = CMTimeMinimum(videoDuration, audioDuration)
|
||||
let range = CMTimeRange(start: .zero, duration: duration)
|
||||
|
||||
try compVideo.insertTimeRange(range, of: srcVideo, at: .zero)
|
||||
try compAudio.insertTimeRange(range, of: srcAudio, at: .zero)
|
||||
|
||||
if FileManager.default.fileExists(atPath: outputURL.path) {
|
||||
try FileManager.default.removeItem(at: outputURL)
|
||||
}
|
||||
|
||||
// Try passthrough (no re-encode) first; fall back to quality export on failure.
|
||||
let presets = [AVAssetExportPresetPassthrough, AVAssetExportPresetHighestQuality]
|
||||
var lastError: Error?
|
||||
|
||||
for preset in presets {
|
||||
guard let exporter = AVAssetExportSession(asset: composition, presetName: preset) else {
|
||||
continue
|
||||
}
|
||||
do {
|
||||
try await exporter.export(to: outputURL, as: .mp4)
|
||||
return
|
||||
} catch {
|
||||
lastError = error
|
||||
// Clean up partial output before retrying with next preset.
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
throw DownloadError.muxFailed(lastError?.localizedDescription ?? "Unknown export failure")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URLSession progress delegate
|
||||
|
||||
private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
|
||||
let onProgress: @Sendable (Double) -> Void
|
||||
|
||||
init(onProgress: @escaping @Sendable (Double) -> Void) {
|
||||
self.onProgress = onProgress
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didWriteData bytesWritten: Int64,
|
||||
totalBytesWritten: Int64,
|
||||
totalBytesExpectedToWrite: Int64
|
||||
) {
|
||||
guard totalBytesExpectedToWrite > 0 else { return }
|
||||
onProgress(Double(totalBytesWritten) / Double(totalBytesExpectedToWrite))
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didFinishDownloadingTo location: URL
|
||||
) {
|
||||
// Not used — `URLSession.download(for:delegate:)` returns the temp URL directly.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// In-session "learning steps" queue for vocab practice — the short-term
|
||||
/// scheduling layer that sits on top of the cross-session SM-2 schedule.
|
||||
///
|
||||
/// A card is requeued a relative number of positions ahead based on the
|
||||
/// rating, mirroring Anki's learning steps but position-based instead of
|
||||
/// time-based:
|
||||
/// Again → reappears 5–8 cards later
|
||||
/// Hard → reappears 7–10 cards later
|
||||
/// Good → first time: advances to `review`, reappears ~20 cards later
|
||||
/// already in review: graduates (leaves the session)
|
||||
/// Easy → graduates immediately
|
||||
///
|
||||
/// `answer` returns a `ReviewQuality` only when the card graduates — that's
|
||||
/// the single rating fed to the long-term `VerbReviewStore`. Intermediate
|
||||
/// Again/Hard presses don't touch the cross-session schedule.
|
||||
struct VocabSessionQueue {
|
||||
|
||||
enum CardState: String {
|
||||
case new // never answered this session
|
||||
case learning // answered Again/Hard at least once
|
||||
case review // answered Good once, one confirmation pass to go
|
||||
}
|
||||
|
||||
enum Rating {
|
||||
case again, hard, good, easy
|
||||
}
|
||||
|
||||
struct Entry: Identifiable {
|
||||
let id = UUID()
|
||||
let verb: Verb
|
||||
var state: CardState
|
||||
}
|
||||
|
||||
private(set) var queue: [Entry]
|
||||
private(set) var learnedCount: Int = 0
|
||||
private let originalVerbs: [Verb]
|
||||
|
||||
init(verbs: [Verb]) {
|
||||
originalVerbs = verbs
|
||||
queue = verbs.map { Entry(verb: $0, state: .new) }
|
||||
}
|
||||
|
||||
/// Resume a persisted group: rebuild the queue from saved (verb, state)
|
||||
/// pairs in order, restoring the learned count. The exact requeue
|
||||
/// positions aren't persisted — the queue order itself is what's saved.
|
||||
init(entries: [(verb: Verb, state: CardState)], learnedCount: Int) {
|
||||
originalVerbs = entries.map(\.verb)
|
||||
queue = entries.map { Entry(verb: $0.verb, state: $0.state) }
|
||||
self.learnedCount = learnedCount
|
||||
}
|
||||
|
||||
/// Current queue (un-graduated cards) in order — for persistence.
|
||||
func snapshot() -> [(verbId: Int, state: CardState)] {
|
||||
queue.map { ($0.verb.id, $0.state) }
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
var current: Entry? { queue.first }
|
||||
var isComplete: Bool { queue.isEmpty }
|
||||
var remainingCount: Int { queue.count }
|
||||
|
||||
/// 0–1, climbs as cards graduate. Requeuing a card lowers it slightly but
|
||||
/// it always trends to 1 as the session drains.
|
||||
var progress: Double {
|
||||
let total = learnedCount + queue.count
|
||||
return total == 0 ? 1 : Double(learnedCount) / Double(total)
|
||||
}
|
||||
|
||||
// MARK: - Answering
|
||||
|
||||
/// Apply a rating to the current card. Returns the `ReviewQuality` to
|
||||
/// record in the long-term SRS *iff* the card graduated; nil if it was
|
||||
/// requeued for more in-session practice.
|
||||
@discardableResult
|
||||
mutating func answer(_ rating: Rating) -> ReviewQuality? {
|
||||
guard !queue.isEmpty else { return nil }
|
||||
var entry = queue.removeFirst()
|
||||
|
||||
switch rating {
|
||||
// Again/Hard always (re)set the card to `.learning`. A card already in
|
||||
// `.review` therefore drops back a step — an intentional lapse, matching
|
||||
// Anki: missing a card you'd previously passed sends it back through the
|
||||
// learning steps rather than letting it graduate.
|
||||
case .again:
|
||||
entry.state = .learning
|
||||
insert(entry, offset: Int.random(in: 5...8))
|
||||
return nil
|
||||
|
||||
case .hard:
|
||||
entry.state = .learning
|
||||
insert(entry, offset: Int.random(in: 7...10))
|
||||
return nil
|
||||
|
||||
case .good:
|
||||
if entry.state == .review {
|
||||
learnedCount += 1
|
||||
return .good
|
||||
}
|
||||
entry.state = .review
|
||||
insert(entry, offset: Int.random(in: 16...24))
|
||||
return nil
|
||||
|
||||
case .easy:
|
||||
learnedCount += 1
|
||||
return .easy
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the session from the same verb set (re-shuffled) — "Study Again".
|
||||
mutating func restart() {
|
||||
queue = originalVerbs.shuffled().map { Entry(verb: $0, state: .new) }
|
||||
learnedCount = 0
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Insert `entry` `offset` positions from the front, i.e. `offset` other
|
||||
/// cards will be shown before it reappears. Clamps to the queue's end.
|
||||
private mutating func insert(_ entry: Entry, offset: Int) {
|
||||
let idx = min(queue.count, offset)
|
||||
queue.insert(entry, at: idx)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session verb pool
|
||||
|
||||
/// Builds a vocab-practice session: level-filtered verbs ordered due-first
|
||||
/// (per the cross-session `VerbReviewCard` schedule) and capped so a single
|
||||
/// sitting is bounded — proper SRS behaviour rather than a 100+ card slog.
|
||||
enum VocabVerbPool {
|
||||
|
||||
/// Maximum verbs in one session, from the "Cards per session" setting
|
||||
/// (`vocabSessionCardLimit`). Defaults to 20 when unset; 999 means "All".
|
||||
/// Overdue cards are pulled first, then new (never-reviewed) verbs.
|
||||
static var sessionCardLimit: Int {
|
||||
let stored = UserDefaults.standard.integer(forKey: "vocabSessionCardLimit")
|
||||
return stored == 0 ? 20 : stored
|
||||
}
|
||||
|
||||
static func sessionVerbs(
|
||||
localContext: ModelContext,
|
||||
cloudContext: ModelContext
|
||||
) -> [Verb] {
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||
let levels = Set(progress.selectedVerbLevels.map(\.rawValue))
|
||||
|
||||
let store = ReferenceStore(context: localContext)
|
||||
let pool = levels.isEmpty
|
||||
? store.fetchVerbs()
|
||||
: store.fetchVerbs(selectedLevels: levels)
|
||||
|
||||
let reviewCards = (try? cloudContext.fetch(FetchDescriptor<VerbReviewCard>())) ?? []
|
||||
let cardByVerbId = Dictionary(
|
||||
reviewCards.map { ($0.verbId, $0) },
|
||||
uniquingKeysWith: { existing, _ in existing }
|
||||
)
|
||||
|
||||
let now = Date()
|
||||
var due: [(verb: Verb, dueDate: Date)] = []
|
||||
var fresh: [Verb] = []
|
||||
for verb in pool {
|
||||
if let card = cardByVerbId[verb.id] {
|
||||
if card.dueDate <= now {
|
||||
due.append((verb, card.dueDate))
|
||||
}
|
||||
// Not yet due → intentionally skipped; that's the SRS schedule.
|
||||
} else {
|
||||
fresh.append(verb)
|
||||
}
|
||||
}
|
||||
|
||||
// Most-overdue first, then new verbs (lower rank = more common first).
|
||||
due.sort { $0.dueDate < $1.dueDate }
|
||||
fresh.sort { $0.rank < $1.rank }
|
||||
|
||||
let ordered = due.map(\.verb) + fresh
|
||||
return Array(ordered.prefix(sessionCardLimit))
|
||||
}
|
||||
|
||||
/// Verbs the user has already studied at least once (have a
|
||||
/// `VerbReviewCard`), most-recently-studied first. Used by the
|
||||
/// "Review Learned" consolidation pass — ignores due dates and the
|
||||
/// Level filter, and is uncapped: it's a deliberate cram over
|
||||
/// everything you've learned.
|
||||
static func reviewLearnedVerbs(
|
||||
localContext: ModelContext,
|
||||
cloudContext: ModelContext
|
||||
) -> [Verb] {
|
||||
let reviewCards = (try? cloudContext.fetch(FetchDescriptor<VerbReviewCard>())) ?? []
|
||||
let sorted = reviewCards.sorted {
|
||||
($0.lastReviewDate ?? .distantPast) > ($1.lastReviewDate ?? .distantPast)
|
||||
}
|
||||
|
||||
let allVerbs = ReferenceStore(context: localContext).fetchVerbs()
|
||||
let byId = Dictionary(allVerbs.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
|
||||
return sorted.compactMap { byId[$0.verbId] }
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical 6-tense set for `VerbExampleGenerator`. Its `@Generable` schema
|
||||
/// requires exactly 6 examples, so callers must pass 6 distinct tense IDs.
|
||||
enum VocabExampleTenseIds {
|
||||
static let canonical: [String] = [
|
||||
TenseID.ind_presente.rawValue,
|
||||
TenseID.ind_preterito.rawValue,
|
||||
TenseID.ind_imperfecto.rawValue,
|
||||
TenseID.ind_futuro.rawValue,
|
||||
TenseID.subj_presente.rawValue,
|
||||
TenseID.imp_afirmativo.rawValue,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
/// Curated YouTube-video lookup for guide + grammar items (Issue #21).
|
||||
/// Loads the bundled `youtube_videos.json` at init, serves tense-guide and
|
||||
/// grammar-note videos by id. The data is static after load; `static let shared`
|
||||
/// lets services access it without environment injection.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class YouTubeVideoStore {
|
||||
|
||||
struct VideoEntry: Codable, Hashable, Sendable, Identifiable {
|
||||
let videoId: String
|
||||
let title: String
|
||||
var id: String { videoId }
|
||||
}
|
||||
|
||||
static let shared = YouTubeVideoStore()
|
||||
|
||||
private(set) var tenseVideos: [String: VideoEntry] = [:]
|
||||
private(set) var grammarVideos: [String: VideoEntry] = [:]
|
||||
|
||||
init(bundle: Bundle = .main) {
|
||||
load(from: bundle)
|
||||
}
|
||||
|
||||
/// Returns the curated video for a tense guide, or nil if unmapped.
|
||||
func video(forTenseId id: String) -> VideoEntry? {
|
||||
tenseVideos[id]
|
||||
}
|
||||
|
||||
/// Returns the curated video for a grammar note, or nil if unmapped.
|
||||
func video(forGrammarNoteId id: String) -> VideoEntry? {
|
||||
grammarVideos[id]
|
||||
}
|
||||
|
||||
/// All distinct videoIds present in the store. Useful for bulk operations
|
||||
/// like "download all" or cache cleanup.
|
||||
var allVideoIds: Set<String> {
|
||||
Set(tenseVideos.values.map(\.videoId)).union(grammarVideos.values.map(\.videoId))
|
||||
}
|
||||
|
||||
private func load(from bundle: Bundle) {
|
||||
guard let url = bundle.url(forResource: "youtube_videos", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url) else {
|
||||
print("[YouTubeVideoStore] bundled youtube_videos.json not found")
|
||||
return
|
||||
}
|
||||
struct Root: Decodable {
|
||||
let tenseGuides: [String: VideoEntry]
|
||||
let grammarNotes: [String: VideoEntry]
|
||||
}
|
||||
do {
|
||||
let root = try JSONDecoder().decode(Root.self, from: data)
|
||||
tenseVideos = root.tenseGuides
|
||||
grammarVideos = root.grammarNotes
|
||||
print("[YouTubeVideoStore] loaded \(tenseVideos.count) tense + \(grammarVideos.count) grammar entries")
|
||||
} catch {
|
||||
print("[YouTubeVideoStore] decode failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,11 @@ final class PracticeViewModel {
|
||||
currentSpans = []
|
||||
hasCards = true
|
||||
isLoading = true
|
||||
let service = PracticeSessionService(localContext: localContext, cloudContext: cloudContext)
|
||||
let service = PracticeSessionService(
|
||||
localContext: localContext,
|
||||
cloudContext: cloudContext,
|
||||
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
|
||||
)
|
||||
guard let cardLoad = service.nextCard(for: focusMode, excludingVerbId: currentVerb?.id) else {
|
||||
clearCurrentCard()
|
||||
hasCards = false
|
||||
|
||||
@@ -585,6 +585,7 @@ struct CourseQuizView: View {
|
||||
)
|
||||
cloudModelContext.insert(result)
|
||||
try? cloudModelContext.save()
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,18 @@ import SwiftData
|
||||
struct CourseView: View {
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Query(sort: \CourseDeck.weekNumber) private var decks: [CourseDeck]
|
||||
@Query(sort: \TextbookChapter.number) private var textbookChapters: [TextbookChapter]
|
||||
@AppStorage("selectedCourse") private var selectedCourse: String?
|
||||
@State private var testResults: [TestResult] = []
|
||||
@State private var extraStudyCounts: [Int: Int] = [:]
|
||||
|
||||
private var textbookCourses: [String] {
|
||||
Array(Set(textbookChapters.map(\.courseName))).sorted()
|
||||
}
|
||||
|
||||
private var activeCourseIsTextbook: Bool {
|
||||
textbookCourses.contains(activeCourse)
|
||||
}
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@@ -62,6 +72,32 @@ struct CourseView: View {
|
||||
description: Text("Course data is loading...")
|
||||
)
|
||||
} else {
|
||||
// Textbook entry (shown above course picker when available)
|
||||
if !textbookCourses.isEmpty {
|
||||
Section {
|
||||
ForEach(textbookCourses, id: \.self) { name in
|
||||
NavigationLink(value: TextbookDestination(courseName: name)) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "book.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.indigo)
|
||||
.frame(width: 32)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Read chapters, do exercises")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Textbook")
|
||||
}
|
||||
}
|
||||
|
||||
// Course picker
|
||||
if courseNames.count > 1 {
|
||||
Section {
|
||||
@@ -138,6 +174,28 @@ struct CourseView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extra Study row — only when there are marks for this week
|
||||
if !activeCourseIsTextbook, let markCount = extraStudyCounts[week], markCount > 0 {
|
||||
NavigationLink(value: ExtraStudyDestination(courseName: activeCourse, weekNumber: week)) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.yellow)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Extra Study")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("\(markCount) marked card\(markCount == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Week \(week)")
|
||||
}
|
||||
@@ -145,22 +203,55 @@ struct CourseView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle(shortName(activeCourse).isEmpty ? "Course" : shortName(activeCourse))
|
||||
.onAppear(perform: loadTestResults)
|
||||
.onAppear {
|
||||
loadTestResults()
|
||||
loadExtraStudyCounts()
|
||||
}
|
||||
.onChange(of: activeCourse) { _, _ in
|
||||
loadExtraStudyCounts()
|
||||
}
|
||||
.navigationDestination(for: CourseDeck.self) { deck in
|
||||
DeckStudyView(deck: deck)
|
||||
}
|
||||
.navigationDestination(for: ExtraStudyDestination.self) { dest in
|
||||
ExtraStudyView(courseName: dest.courseName, weekNumber: dest.weekNumber)
|
||||
}
|
||||
.navigationDestination(for: WeekTestDestination.self) { dest in
|
||||
WeekTestView(courseName: dest.courseName, weekNumber: dest.weekNumber)
|
||||
}
|
||||
.navigationDestination(for: CheckpointDestination.self) { dest in
|
||||
CheckpointExamView(courseName: dest.courseName, throughWeek: dest.throughWeek)
|
||||
}
|
||||
.navigationDestination(for: TextbookDestination.self) { dest in
|
||||
TextbookChapterListView(courseName: dest.courseName)
|
||||
}
|
||||
.navigationDestination(for: TextbookChapter.self) { chapter in
|
||||
TextbookChapterView(chapter: chapter)
|
||||
}
|
||||
.navigationDestination(for: TextbookExerciseDestination.self) { dest in
|
||||
textbookExerciseView(for: dest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func textbookExerciseView(for dest: TextbookExerciseDestination) -> some View {
|
||||
if let chapter = textbookChapters.first(where: { $0.id == dest.chapterId }) {
|
||||
TextbookExerciseView(chapter: chapter, blockIndex: dest.blockIndex)
|
||||
} else {
|
||||
ContentUnavailableView("Exercise unavailable", systemImage: "questionmark.circle")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadTestResults() {
|
||||
testResults = (try? cloudModelContext.fetch(FetchDescriptor<TestResult>())) ?? []
|
||||
}
|
||||
|
||||
private func loadExtraStudyCounts() {
|
||||
guard !activeCourse.isEmpty else { return }
|
||||
extraStudyCounts = ExtraStudyStore(context: cloudModelContext)
|
||||
.countsByWeek(courseName: activeCourse)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
@@ -175,6 +266,15 @@ struct CheckpointDestination: Hashable {
|
||||
let throughWeek: Int
|
||||
}
|
||||
|
||||
struct TextbookDestination: Hashable {
|
||||
let courseName: String
|
||||
}
|
||||
|
||||
struct ExtraStudyDestination: Hashable {
|
||||
let courseName: String
|
||||
let weekNumber: Int
|
||||
}
|
||||
|
||||
// MARK: - Deck Row
|
||||
|
||||
private struct DeckRowView: View {
|
||||
|
||||
@@ -5,9 +5,29 @@ import SwiftData
|
||||
struct DeckStudyView: View {
|
||||
let deck: CourseDeck
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Query private var textbookChapters: [TextbookChapter]
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
@State private var isStudying = false
|
||||
@State private var speechService = SpeechService()
|
||||
@State private var deckCards: [VocabCard] = []
|
||||
@State private var expandedConjugations: Set<String> = []
|
||||
|
||||
private var isStemChangingDeck: Bool {
|
||||
deck.title.localizedCaseInsensitiveContains("stem changing")
|
||||
}
|
||||
|
||||
private var isTextbookDeck: Bool {
|
||||
textbookChapters.contains { $0.courseName == deck.courseName }
|
||||
}
|
||||
|
||||
private var markContext: ExtraStudyMarkContext? {
|
||||
guard !isTextbookDeck else { return nil }
|
||||
return ExtraStudyMarkContext(
|
||||
courseName: deck.courseName,
|
||||
weekNumber: deck.weekNumber
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
cardListView
|
||||
@@ -19,7 +39,12 @@ struct DeckStudyView: View {
|
||||
VocabFlashcardView(
|
||||
cards: deckCards.shuffled(),
|
||||
speechService: speechService,
|
||||
onDone: { isStudying = false }
|
||||
onDone: {
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
isStudying = false
|
||||
},
|
||||
deckTitle: deck.title,
|
||||
markContext: markContext
|
||||
)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
@@ -30,6 +55,24 @@ struct DeckStudyView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reversed stem-change decks have `front` as English, so prefer the
|
||||
/// Spanish side when the card is stored that way. Strip parenthetical
|
||||
/// notes and the reflexive `-se` ending for verb-table lookup.
|
||||
private func inferInfinitive(card: VocabCard) -> String {
|
||||
let raw: String
|
||||
if deck.isReversed {
|
||||
raw = card.back
|
||||
} else {
|
||||
raw = card.front
|
||||
}
|
||||
var t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let paren = t.firstIndex(of: "(") {
|
||||
t = String(t[..<paren]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
if t.hasSuffix("se") && t.count > 4 { t = String(t.dropLast(2)) }
|
||||
return t
|
||||
}
|
||||
|
||||
private func loadCards() {
|
||||
let deckId = deck.id
|
||||
let descriptor = FetchDescriptor<VocabCard>(
|
||||
@@ -107,6 +150,36 @@ struct DeckStudyView: View {
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
|
||||
// Stem-change conjugation toggle
|
||||
if isStemChangingDeck {
|
||||
let verb = inferInfinitive(card: card)
|
||||
let isOpen = expandedConjugations.contains(verb)
|
||||
Button {
|
||||
withAnimation(.smooth) {
|
||||
if isOpen {
|
||||
expandedConjugations.remove(verb)
|
||||
} else {
|
||||
expandedConjugations.insert(verb)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
isOpen ? "Hide conjugation" : "Show conjugation",
|
||||
systemImage: isOpen ? "chevron.up" : "chevron.down"
|
||||
)
|
||||
.font(.caption.weight(.medium))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.tint(.blue)
|
||||
.padding(.leading, 42)
|
||||
|
||||
if isOpen {
|
||||
StemChangeConjugationView(infinitive: verb)
|
||||
.padding(.leading, 42)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
|
||||
// Example sentences
|
||||
if !card.examplesES.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Study session containing only cards the user has marked for extra study,
|
||||
/// scoped to a specific (courseName, weekNumber). Resolves marks by re-hashing
|
||||
/// each VocabCard via `CourseCardStore.reviewKey` so the matching is robust to
|
||||
/// duplicate (deckId, front, back) tuples that differ in examples.
|
||||
struct ExtraStudyView: View {
|
||||
let courseName: String
|
||||
let weekNumber: Int
|
||||
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var cards: [VocabCard] = []
|
||||
@State private var speechService = SpeechService()
|
||||
@State private var loaded = false
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !loaded {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
} else if cards.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Marked Cards",
|
||||
systemImage: "star",
|
||||
description: Text("Tap the star on a card during study to add it here.")
|
||||
)
|
||||
} else {
|
||||
VocabFlashcardView(
|
||||
cards: cards.shuffled(),
|
||||
speechService: speechService,
|
||||
onDone: {
|
||||
ReviewStore.recordActivity(context: cloudContext)
|
||||
dismiss()
|
||||
},
|
||||
deckTitle: "Extra Study",
|
||||
markContext: ExtraStudyMarkContext(
|
||||
courseName: courseName,
|
||||
weekNumber: weekNumber
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Extra Study · Week \(weekNumber)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { load() }
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard !loaded else { return }
|
||||
let marks = ExtraStudyStore(context: cloudContext)
|
||||
.fetch(courseName: courseName, weekNumber: weekNumber)
|
||||
let markIds = Set(marks.map(\.id))
|
||||
let deckIds = Set(marks.map(\.deckId))
|
||||
|
||||
var collected: [VocabCard] = []
|
||||
for deckId in deckIds {
|
||||
let descriptor = FetchDescriptor<VocabCard>(
|
||||
predicate: #Predicate<VocabCard> { $0.deckId == deckId }
|
||||
)
|
||||
let deckCards = (try? localContext.fetch(descriptor)) ?? []
|
||||
collected.append(contentsOf: deckCards.filter {
|
||||
markIds.contains(CourseCardStore.reviewKey(for: $0))
|
||||
})
|
||||
}
|
||||
cards = collected
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Shows the present-tense conjugation of a verb (identified by infinitive),
|
||||
/// with any irregular/stem-change spans highlighted. Designed to drop into
|
||||
/// stem-changing verb flashcards so learners can see the conjugation in-place.
|
||||
struct StemChangeConjugationView: View {
|
||||
let infinitive: String
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@State private var rows: [ConjugationRow] = []
|
||||
|
||||
private static let personLabels = ["yo", "tú", "él/ella/Ud.", "nosotros", "vosotros", "ellos/ellas/Uds."]
|
||||
private static let tenseId = "ind_presente"
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Present tense")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
if rows.isEmpty {
|
||||
Text("Conjugation not available")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
VStack(spacing: 6) {
|
||||
ForEach(rows) { row in
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(row.person)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .leading)
|
||||
IrregularHighlightText(
|
||||
form: row.form,
|
||||
spans: row.spans,
|
||||
font: .callout.monospaced(),
|
||||
showLabels: false
|
||||
)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.blue.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
|
||||
.onAppear(perform: loadForms)
|
||||
}
|
||||
|
||||
private func loadForms() {
|
||||
// Find the verb by infinitive (lowercase exact match).
|
||||
let normalized = infinitive.lowercased().trimmingCharacters(in: .whitespaces)
|
||||
let verbDescriptor = FetchDescriptor<Verb>(
|
||||
predicate: #Predicate<Verb> { $0.infinitive == normalized }
|
||||
)
|
||||
guard let verb = (try? modelContext.fetch(verbDescriptor))?.first else {
|
||||
rows = []
|
||||
return
|
||||
}
|
||||
|
||||
let verbId = verb.id
|
||||
let tenseId = Self.tenseId
|
||||
let formDescriptor = FetchDescriptor<VerbForm>(
|
||||
predicate: #Predicate<VerbForm> { $0.verbId == verbId && $0.tenseId == tenseId },
|
||||
sortBy: [SortDescriptor(\VerbForm.personIndex)]
|
||||
)
|
||||
let forms = (try? modelContext.fetch(formDescriptor)) ?? []
|
||||
|
||||
rows = forms.map { f in
|
||||
ConjugationRow(
|
||||
id: f.personIndex,
|
||||
person: Self.personLabels[safe: f.personIndex] ?? "",
|
||||
form: f.form,
|
||||
spans: f.spans ?? []
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConjugationRow: Identifiable {
|
||||
let id: Int
|
||||
let person: String
|
||||
let form: String
|
||||
let spans: [IrregularSpan]
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct TextbookChapterListView: View {
|
||||
let courseName: String
|
||||
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Query(sort: \TextbookChapter.number) private var allChapters: [TextbookChapter]
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
@State private var attempts: [TextbookExerciseAttempt] = []
|
||||
|
||||
private var chapters: [TextbookChapter] {
|
||||
allChapters.filter { $0.courseName == courseName }
|
||||
}
|
||||
|
||||
private var byPart: [(part: Int, chapters: [TextbookChapter])] {
|
||||
let grouped = Dictionary(grouping: chapters, by: \.part)
|
||||
return grouped.keys.sorted().map { p in
|
||||
(p, grouped[p]!.sorted { $0.number < $1.number })
|
||||
}
|
||||
}
|
||||
|
||||
private func progressFor(_ chapter: TextbookChapter) -> (correct: Int, total: Int) {
|
||||
let chNum = chapter.number
|
||||
let chAttempts = attempts.filter {
|
||||
$0.courseName == courseName && $0.chapterNumber == chNum
|
||||
}
|
||||
let total = chAttempts.reduce(0) { $0 + $1.totalCount }
|
||||
let correct = chAttempts.reduce(0) { $0 + $1.correctCount + $1.closeCount }
|
||||
return (correct, total)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if chapters.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Textbook loading",
|
||||
systemImage: "book.closed",
|
||||
description: Text("Textbook content is being prepared…")
|
||||
)
|
||||
} else {
|
||||
ForEach(byPart, id: \.part) { part, partChapters in
|
||||
Section {
|
||||
ForEach(partChapters, id: \.id) { chapter in
|
||||
NavigationLink(value: chapter) {
|
||||
chapterRow(chapter)
|
||||
}
|
||||
.accessibilityIdentifier("textbook-chapter-row-\(chapter.number)")
|
||||
}
|
||||
} header: {
|
||||
if part > 0 {
|
||||
Text("Part \(part)")
|
||||
} else {
|
||||
Text("Chapters")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Textbook")
|
||||
.onAppear(perform: loadAttempts)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func chapterRow(_ chapter: TextbookChapter) -> some View {
|
||||
let p = progressFor(chapter)
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.secondary.opacity(0.2), lineWidth: 3)
|
||||
.frame(width: 36, height: 36)
|
||||
if p.total > 0 {
|
||||
Circle()
|
||||
.trim(from: 0, to: CGFloat(p.correct) / CGFloat(p.total))
|
||||
.stroke(.orange, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||||
.frame(width: 36, height: 36)
|
||||
.rotationEffect(.degrees(-90))
|
||||
}
|
||||
Text("\(chapter.number)")
|
||||
.font(.footnote.weight(.bold))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(chapter.title)
|
||||
.font(.headline)
|
||||
HStack(spacing: 10) {
|
||||
if chapter.exerciseCount > 0 {
|
||||
Label("\(chapter.exerciseCount)", systemImage: "pencil.and.list.clipboard")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if chapter.vocabTableCount > 0 {
|
||||
Label("\(chapter.vocabTableCount)", systemImage: "list.bullet.rectangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if p.total > 0 {
|
||||
Text("\(p.correct)/\(p.total)")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func loadAttempts() {
|
||||
attempts = (try? cloudModelContext.fetch(FetchDescriptor<TextbookExerciseAttempt>())) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
TextbookChapterListView(courseName: "Complete Spanish Step-by-Step")
|
||||
}
|
||||
.modelContainer(for: [TextbookChapter.self], inMemory: true)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct TextbookChapterView: View {
|
||||
let chapter: TextbookChapter
|
||||
|
||||
@State private var expandedVocab: Set<Int> = []
|
||||
|
||||
private var blocks: [TextbookBlock] { chapter.blocks() }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
headerView
|
||||
Divider()
|
||||
ForEach(blocks) { block in
|
||||
blockView(block)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.navigationTitle(chapter.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if chapter.part > 0 {
|
||||
Text("Part \(chapter.part)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Chapter \(chapter.number)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(chapter.title)
|
||||
.font(.largeTitle.bold())
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func blockView(_ block: TextbookBlock) -> some View {
|
||||
switch block.kind {
|
||||
case .heading:
|
||||
headingView(block)
|
||||
case .paragraph:
|
||||
paragraphView(block)
|
||||
case .keyVocabHeader:
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "star.fill").foregroundStyle(.orange)
|
||||
Text("Key Vocabulary")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
case .vocabTable:
|
||||
vocabTableView(block)
|
||||
case .exercise:
|
||||
exerciseLinkView(block)
|
||||
}
|
||||
}
|
||||
|
||||
private func headingView(_ block: TextbookBlock) -> some View {
|
||||
let level = block.level ?? 3
|
||||
let font: Font
|
||||
switch level {
|
||||
case 2: font = .title.bold()
|
||||
case 3: font = .title2.bold()
|
||||
case 4: font = .title3.weight(.semibold)
|
||||
default: font = .headline
|
||||
}
|
||||
return Text(stripInlineEmphasis(block.text ?? ""))
|
||||
.font(font)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
|
||||
private func paragraphView(_ block: TextbookBlock) -> some View {
|
||||
Text(attributedFromMarkdownish(block.text ?? ""))
|
||||
.font(.body)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
private func vocabTableView(_ block: TextbookBlock) -> some View {
|
||||
let expanded = expandedVocab.contains(block.index)
|
||||
let cards = block.cards ?? []
|
||||
let lines = block.ocrLines ?? []
|
||||
let itemCount = cards.isEmpty ? lines.count : cards.count
|
||||
return VStack(alignment: .leading, spacing: 4) {
|
||||
Button {
|
||||
if expanded { expandedVocab.remove(block.index) } else { expandedVocab.insert(block.index) }
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: expanded ? "chevron.down" : "chevron.right")
|
||||
.font(.caption)
|
||||
Text("Vocabulary (\(itemCount) items)")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if expanded {
|
||||
if cards.isEmpty {
|
||||
// Fallback: no paired cards available — show raw OCR lines.
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
||||
Text(line)
|
||||
.font(.callout.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 14)
|
||||
} else {
|
||||
vocabGrid(cards: cards)
|
||||
.padding(.leading, 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func vocabGrid(cards: [TextbookVocabPair]) -> some View {
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
|
||||
ForEach(Array(cards.enumerated()), id: \.offset) { _, card in
|
||||
GridRow {
|
||||
Text(card.front)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.primary)
|
||||
Text(card.back)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func exerciseLinkView(_ block: TextbookBlock) -> some View {
|
||||
NavigationLink(value: TextbookExerciseDestination(
|
||||
chapterId: chapter.id,
|
||||
chapterNumber: chapter.number,
|
||||
blockIndex: block.index
|
||||
)) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "pencil.and.list.clipboard")
|
||||
.foregroundStyle(.orange)
|
||||
.font(.title3)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Exercise \(block.exerciseId ?? "")")
|
||||
.font(.headline)
|
||||
if let inst = block.instruction, !inst.isEmpty {
|
||||
Text(stripInlineEmphasis(inst))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// Strip our ad-hoc ** / * markers from parsed text
|
||||
private func stripInlineEmphasis(_ s: String) -> String {
|
||||
s.replacingOccurrences(of: "**", with: "")
|
||||
.replacingOccurrences(of: "*", with: "")
|
||||
}
|
||||
|
||||
private func attributedFromMarkdownish(_ s: String) -> AttributedString {
|
||||
// Parser emits `**bold**` and `*italic*`. Try to render via AttributedString markdown.
|
||||
if let parsed = try? AttributedString(markdown: s, options: .init(allowsExtendedAttributes: true)) {
|
||||
return parsed
|
||||
}
|
||||
return AttributedString(stripInlineEmphasis(s))
|
||||
}
|
||||
}
|
||||
|
||||
struct TextbookExerciseDestination: Hashable {
|
||||
let chapterId: String
|
||||
let chapterNumber: Int
|
||||
let blockIndex: Int
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
TextbookChapterView(chapter: TextbookChapter(
|
||||
id: "ch1",
|
||||
number: 1,
|
||||
title: "Sample",
|
||||
part: 1,
|
||||
courseName: "Preview",
|
||||
bodyJSON: Data(),
|
||||
exerciseCount: 0,
|
||||
vocabTableCount: 0
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
import PencilKit
|
||||
|
||||
/// Interactive fill-in-the-blank view for one textbook exercise.
|
||||
/// Supports keyboard typing OR Apple Pencil handwriting input per prompt.
|
||||
struct TextbookExerciseView: View {
|
||||
let chapter: TextbookChapter
|
||||
let blockIndex: Int
|
||||
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@State private var answers: [Int: String] = [:]
|
||||
@State private var drawings: [Int: PKDrawing] = [:]
|
||||
@State private var grades: [Int: TextbookGrade] = [:]
|
||||
@State private var inputMode: InputMode = .keyboard
|
||||
@State private var activePencilPromptNumber: Int?
|
||||
@State private var isRecognizing = false
|
||||
@State private var isChecked = false
|
||||
@State private var recognizedTextForActive: String = ""
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
enum InputMode: String {
|
||||
case keyboard
|
||||
case pencil
|
||||
}
|
||||
|
||||
private var block: TextbookBlock? {
|
||||
chapter.blocks().first { $0.index == blockIndex }
|
||||
}
|
||||
|
||||
private var answerByNumber: [Int: TextbookAnswerItem] {
|
||||
guard let items = block?.answerItems else { return [:] }
|
||||
var out: [Int: TextbookAnswerItem] = [:]
|
||||
for it in items {
|
||||
out[it.number] = it
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if let b = block {
|
||||
headerView(b)
|
||||
inputModePicker
|
||||
exerciseBody(b)
|
||||
checkButton(b)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Exercise not found",
|
||||
systemImage: "questionmark.circle"
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Exercise \(block?.exerciseId ?? "")")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadPreviousAttempt)
|
||||
}
|
||||
|
||||
private func headerView(_ b: TextbookBlock) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Chapter \(chapter.number): \(chapter.title)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Exercise \(b.exerciseId ?? "")")
|
||||
.font(.title2.bold())
|
||||
if let inst = b.instruction, !inst.isEmpty {
|
||||
Text(stripInlineEmphasis(inst))
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
if let extra = b.extra, !extra.isEmpty {
|
||||
ForEach(Array(extra.enumerated()), id: \.offset) { _, e in
|
||||
Text(stripInlineEmphasis(e))
|
||||
.font(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var inputModePicker: some View {
|
||||
Picker("Input mode", selection: $inputMode) {
|
||||
Label("Keyboard", systemImage: "keyboard").tag(InputMode.keyboard)
|
||||
Label("Pencil", systemImage: "pencil.tip").tag(InputMode.pencil)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
private func exerciseBody(_ b: TextbookBlock) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if b.freeform == true {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("Freeform exercise", systemImage: "text.bubble")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.orange)
|
||||
Text("Answers will vary. Use this space to write your own responses; they won't be auto-checked.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
let rawPrompts = b.prompts ?? []
|
||||
let prompts = rawPrompts.isEmpty ? synthesizedPrompts(b) : rawPrompts
|
||||
if prompts.isEmpty && b.extra?.isEmpty == false {
|
||||
Text("Fill in the blanks above; answers will be graded when you tap Check.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(Array(prompts.enumerated()), id: \.offset) { i, prompt in
|
||||
promptRow(index: i, prompt: prompt, expected: answerByNumber[i + 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When the source exercise prompts were embedded in a bitmap (common in
|
||||
/// this textbook), we have no text for each question — only the answer
|
||||
/// key. Synthesize numbered placeholders so the user still gets one input
|
||||
/// field per answer.
|
||||
private func synthesizedPrompts(_ b: TextbookBlock) -> [String] {
|
||||
guard let items = b.answerItems, !items.isEmpty else { return [] }
|
||||
return items.map { "\($0.number)." }
|
||||
}
|
||||
|
||||
private func promptRow(index: Int, prompt: String, expected: TextbookAnswerItem?) -> some View {
|
||||
let number = index + 1
|
||||
let grade = grades[number]
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
if let grade {
|
||||
Image(systemName: iconFor(grade))
|
||||
.foregroundStyle(colorFor(grade))
|
||||
.font(.title3)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
Text(stripInlineEmphasis(prompt))
|
||||
.font(.body)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
switch inputMode {
|
||||
case .keyboard:
|
||||
TextField("Your answer", text: binding(for: number))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.font(.body)
|
||||
.disabled(isChecked)
|
||||
case .pencil:
|
||||
pencilRow(number: number)
|
||||
}
|
||||
|
||||
if isChecked, let grade, grade != .correct, let expected {
|
||||
HStack(spacing: 6) {
|
||||
Text("Answer:")
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(expected.answer)
|
||||
.font(.caption)
|
||||
if !expected.alternates.isEmpty {
|
||||
Text("(also: \(expected.alternates.joined(separator: ", ")))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(colorFor(grade))
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(backgroundFor(grade), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private func pencilRow(number: Int) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HandwritingCanvas(
|
||||
drawing: bindingDrawing(for: number),
|
||||
onDrawingChanged: { recognizePencil(for: number) }
|
||||
)
|
||||
.frame(height: 100)
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.separator, lineWidth: 1))
|
||||
|
||||
HStack {
|
||||
if let typed = answers[number], !typed.isEmpty {
|
||||
Text("Recognized: \(typed)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Clear") {
|
||||
drawings[number] = PKDrawing()
|
||||
answers[number] = ""
|
||||
}
|
||||
.font(.caption)
|
||||
.tint(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkButton(_ b: TextbookBlock) -> some View {
|
||||
let hasAnyAnswer = answers.values.contains { !$0.isEmpty }
|
||||
let disabled = b.freeform == true || (!isChecked && !hasAnyAnswer)
|
||||
return Button {
|
||||
if isChecked {
|
||||
resetExercise()
|
||||
} else {
|
||||
checkAnswers(b)
|
||||
}
|
||||
} label: {
|
||||
Text(isChecked ? "Try again" : "Check answers")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.orange)
|
||||
.disabled(disabled)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func checkAnswers(_ b: TextbookBlock) {
|
||||
guard let prompts = b.prompts else { return }
|
||||
var newGrades: [Int: TextbookGrade] = [:]
|
||||
var states: [TextbookPromptState] = []
|
||||
for (i, _) in prompts.enumerated() {
|
||||
let number = i + 1
|
||||
let user = answers[number] ?? ""
|
||||
let expected = answerByNumber[number]
|
||||
let canonical = expected?.answer ?? ""
|
||||
let alts = expected?.alternates ?? []
|
||||
let grade: TextbookGrade
|
||||
if canonical.isEmpty {
|
||||
grade = .wrong
|
||||
} else {
|
||||
grade = AnswerChecker.grade(userText: user, canonical: canonical, alternates: alts)
|
||||
}
|
||||
newGrades[number] = grade
|
||||
states.append(TextbookPromptState(number: number, userText: user, grade: grade))
|
||||
}
|
||||
grades = newGrades
|
||||
isChecked = true
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
saveAttempt(states: states, exerciseId: b.exerciseId ?? "")
|
||||
}
|
||||
|
||||
private func resetExercise() {
|
||||
answers.removeAll()
|
||||
drawings.removeAll()
|
||||
grades.removeAll()
|
||||
isChecked = false
|
||||
}
|
||||
|
||||
private func recognizePencil(for number: Int) {
|
||||
guard let drawing = drawings[number], !drawing.strokes.isEmpty else { return }
|
||||
isRecognizing = true
|
||||
Task {
|
||||
let result = await HandwritingRecognizer.recognize(drawing: drawing)
|
||||
await MainActor.run {
|
||||
answers[number] = result.text
|
||||
isRecognizing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAttempt(states: [TextbookPromptState], exerciseId: String) {
|
||||
let attemptId = TextbookExerciseAttempt.attemptId(
|
||||
courseName: chapter.courseName,
|
||||
exerciseId: exerciseId
|
||||
)
|
||||
let descriptor = FetchDescriptor<TextbookExerciseAttempt>(
|
||||
predicate: #Predicate<TextbookExerciseAttempt> { $0.id == attemptId }
|
||||
)
|
||||
let context = cloudModelContext
|
||||
let existing = (try? context.fetch(descriptor))?.first
|
||||
let attempt = existing ?? TextbookExerciseAttempt(
|
||||
id: attemptId,
|
||||
courseName: chapter.courseName,
|
||||
chapterNumber: chapter.number,
|
||||
exerciseId: exerciseId
|
||||
)
|
||||
if existing == nil { context.insert(attempt) }
|
||||
attempt.lastAttemptAt = Date()
|
||||
attempt.setPromptStates(states)
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
private func loadPreviousAttempt() {
|
||||
guard let b = block else { return }
|
||||
let attemptId = TextbookExerciseAttempt.attemptId(
|
||||
courseName: chapter.courseName,
|
||||
exerciseId: b.exerciseId ?? ""
|
||||
)
|
||||
let descriptor = FetchDescriptor<TextbookExerciseAttempt>(
|
||||
predicate: #Predicate<TextbookExerciseAttempt> { $0.id == attemptId }
|
||||
)
|
||||
guard let attempt = (try? cloudModelContext.fetch(descriptor))?.first else { return }
|
||||
for s in attempt.promptStates() {
|
||||
answers[s.number] = s.userText
|
||||
grades[s.number] = s.grade
|
||||
}
|
||||
isChecked = !grades.isEmpty
|
||||
}
|
||||
|
||||
// MARK: - Bindings
|
||||
|
||||
private func binding(for number: Int) -> Binding<String> {
|
||||
Binding(
|
||||
get: { answers[number] ?? "" },
|
||||
set: { answers[number] = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
private func bindingDrawing(for number: Int) -> Binding<PKDrawing> {
|
||||
Binding(
|
||||
get: { drawings[number] ?? PKDrawing() },
|
||||
set: { drawings[number] = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - UI helpers
|
||||
|
||||
private func iconFor(_ grade: TextbookGrade) -> String {
|
||||
switch grade {
|
||||
case .correct: return "checkmark.circle.fill"
|
||||
case .close: return "circle.lefthalf.filled"
|
||||
case .wrong: return "xmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private func colorFor(_ grade: TextbookGrade) -> Color {
|
||||
switch grade {
|
||||
case .correct: return .green
|
||||
case .close: return .orange
|
||||
case .wrong: return .red
|
||||
}
|
||||
}
|
||||
|
||||
private func backgroundFor(_ grade: TextbookGrade?) -> Color {
|
||||
guard let grade else { return Color.secondary.opacity(0.05) }
|
||||
switch grade {
|
||||
case .correct: return .green.opacity(0.12)
|
||||
case .close: return .orange.opacity(0.12)
|
||||
case .wrong: return .red.opacity(0.12)
|
||||
}
|
||||
}
|
||||
|
||||
private func stripInlineEmphasis(_ s: String) -> String {
|
||||
s.replacingOccurrences(of: "**", with: "")
|
||||
.replacingOccurrences(of: "*", with: "")
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,23 @@ struct VocabFlashcardView: View {
|
||||
let cards: [VocabCard]
|
||||
let speechService: SpeechService
|
||||
let onDone: () -> Void
|
||||
/// Optional deck context — when present and the title indicates a stem-
|
||||
/// changing deck, each card gets an inline conjugation toggle.
|
||||
var deckTitle: String? = nil
|
||||
/// When set, a star button appears next to the speaker on reveal so the
|
||||
/// user can mark the card for extra study.
|
||||
var markContext: ExtraStudyMarkContext? = nil
|
||||
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@State private var currentIndex = 0
|
||||
@State private var isRevealed = false
|
||||
@State private var sessionCorrect = 0
|
||||
@State private var showConjugation = false
|
||||
@State private var markedIds: Set<String> = []
|
||||
|
||||
private var isStemChangingDeck: Bool {
|
||||
(deckTitle ?? "").localizedCaseInsensitiveContains("stem changing")
|
||||
}
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@@ -53,14 +65,48 @@ struct VocabFlashcardView: View {
|
||||
.font(.title.weight(.medium))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button {
|
||||
speechService.speak(card.front)
|
||||
} label: {
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.font(.title3)
|
||||
.padding(12)
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
speechService.speak(card.front)
|
||||
} label: {
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.font(.title3)
|
||||
.padding(12)
|
||||
}
|
||||
.glassEffect(in: .circle)
|
||||
|
||||
if markContext != nil {
|
||||
Button {
|
||||
toggleMark()
|
||||
} label: {
|
||||
Image(systemName: currentIsMarked ? "star.fill" : "star")
|
||||
.font(.title3)
|
||||
.padding(12)
|
||||
.foregroundStyle(currentIsMarked ? .yellow : .secondary)
|
||||
}
|
||||
.glassEffect(in: .circle)
|
||||
.accessibilityLabel(currentIsMarked ? "Unmark for extra study" : "Mark for extra study")
|
||||
}
|
||||
}
|
||||
|
||||
if isStemChangingDeck {
|
||||
Button {
|
||||
withAnimation(.smooth) { showConjugation.toggle() }
|
||||
} label: {
|
||||
Label(
|
||||
showConjugation ? "Hide conjugation" : "Show conjugation",
|
||||
systemImage: showConjugation ? "chevron.up" : "chevron.down"
|
||||
)
|
||||
.font(.subheadline.weight(.medium))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.blue)
|
||||
|
||||
if showConjugation {
|
||||
StemChangeConjugationView(infinitive: stripToInfinitive(card.front))
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.glassEffect(in: .circle)
|
||||
}
|
||||
.transition(.blurReplace)
|
||||
} else {
|
||||
@@ -111,6 +157,7 @@ struct VocabFlashcardView: View {
|
||||
guard currentIndex > 0 else { return }
|
||||
withAnimation(.smooth) {
|
||||
isRevealed = false
|
||||
showConjugation = false
|
||||
currentIndex -= 1
|
||||
}
|
||||
} label: {
|
||||
@@ -125,6 +172,7 @@ struct VocabFlashcardView: View {
|
||||
Button {
|
||||
withAnimation(.smooth) {
|
||||
isRevealed = false
|
||||
showConjugation = false
|
||||
currentIndex += 1
|
||||
}
|
||||
} label: {
|
||||
@@ -165,6 +213,34 @@ struct VocabFlashcardView: View {
|
||||
}
|
||||
.animation(.smooth, value: isRevealed)
|
||||
.animation(.smooth, value: currentIndex)
|
||||
.task { loadMarks() }
|
||||
}
|
||||
|
||||
private var currentIsMarked: Bool {
|
||||
guard let card = currentCard else { return false }
|
||||
return markedIds.contains(CourseCardStore.reviewKey(for: card))
|
||||
}
|
||||
|
||||
private func loadMarks() {
|
||||
guard let ctx = markContext else { return }
|
||||
markedIds = ExtraStudyStore(context: cloudModelContext)
|
||||
.fetchIds(courseName: ctx.courseName, weekNumber: ctx.weekNumber)
|
||||
}
|
||||
|
||||
private func toggleMark() {
|
||||
guard let card = currentCard, let ctx = markContext else { return }
|
||||
let store = ExtraStudyStore(context: cloudModelContext)
|
||||
let isNowMarked = store.toggle(
|
||||
card: card,
|
||||
courseName: ctx.courseName,
|
||||
weekNumber: ctx.weekNumber
|
||||
)
|
||||
let key = CourseCardStore.reviewKey(for: card)
|
||||
if isNowMarked {
|
||||
markedIds.insert(key)
|
||||
} else {
|
||||
markedIds.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
|
||||
@@ -189,9 +265,25 @@ struct VocabFlashcardView: View {
|
||||
// Next card
|
||||
withAnimation(.smooth) {
|
||||
isRevealed = false
|
||||
showConjugation = false
|
||||
currentIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Card fronts may be plain infinitives ("cerrar") or, in reversed decks,
|
||||
/// stored as English. Strip any reflexive-se suffix or parenthetical notes
|
||||
/// to improve the verb lookup hit rate.
|
||||
private func stripToInfinitive(_ s: String) -> String {
|
||||
var t = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let paren = t.firstIndex(of: "(") {
|
||||
t = String(t[..<paren]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
if t.hasSuffix("se") && t.count > 4 {
|
||||
// "acostarse" → "acostar" for verb lookup
|
||||
t = String(t.dropLast(2))
|
||||
}
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@@ -288,7 +288,10 @@ struct DashboardView: View {
|
||||
}
|
||||
|
||||
private func loadData() {
|
||||
userProgress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
// Reset a stale streak before rendering so the dashboard never lies.
|
||||
progress.validateStreakIfStale(context: cloudModelContext)
|
||||
userProgress = progress
|
||||
let dailyDescriptor = FetchDescriptor<DailyLog>(
|
||||
sortBy: [SortDescriptor(\DailyLog.dateString, order: .reverse)]
|
||||
)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct GrammarExerciseView: View {
|
||||
let noteId: String
|
||||
let noteTitle: String
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@State private var exercises: [GrammarExercise] = []
|
||||
@State private var currentIndex = 0
|
||||
@@ -23,7 +26,7 @@ struct GrammarExerciseView: View {
|
||||
.adaptiveContainer(maxWidth: 600)
|
||||
.navigationTitle("Practice: \(noteTitle)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear { exercises = GrammarExercise.exercises(for: noteId).shuffled() }
|
||||
.onAppear { exercises = Array(GrammarExercise.exercises(for: noteId).shuffled().prefix(10)) }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -96,6 +99,7 @@ struct GrammarExerciseView: View {
|
||||
currentIndex += 1
|
||||
selectedOption = nil
|
||||
} else {
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
withAnimation { isFinished = true }
|
||||
}
|
||||
} label: {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
|
||||
/// Standalone grammar notes view with its own NavigationStack (used outside GuideView).
|
||||
struct GrammarNotesView: View {
|
||||
@@ -19,9 +21,9 @@ struct GrammarNotesListView: View {
|
||||
@Binding var selectedNote: GrammarNote?
|
||||
|
||||
private var groupedNotes: [(String, [GrammarNote])] {
|
||||
let grouped = Dictionary(grouping: GrammarNote.allNotes, by: \.category)
|
||||
let grouped = Dictionary(grouping: GrammarNote.allNotesIncludingGenerated, by: \.category)
|
||||
var seen: [String] = []
|
||||
for note in GrammarNote.allNotes {
|
||||
for note in GrammarNote.allNotesIncludingGenerated {
|
||||
if !seen.contains(note.category) {
|
||||
seen.append(note.category)
|
||||
}
|
||||
@@ -67,6 +69,24 @@ private struct GrammarNoteRow: View {
|
||||
|
||||
struct GrammarNoteDetailView: View {
|
||||
let note: GrammarNote
|
||||
var onJumpToTense: ((TenseGuide) -> Void)? = nil
|
||||
@Environment(YouTubeVideoStore.self) private var videoStore
|
||||
@State private var relatedTenses: [TenseGuide] = []
|
||||
|
||||
private var curatedVideo: YouTubeVideoStore.VideoEntry? {
|
||||
videoStore.video(forGrammarNoteId: note.id)
|
||||
}
|
||||
|
||||
private func loadRelatedTenses() {
|
||||
guard let container = SharedStore.localContainer else {
|
||||
relatedTenses = []
|
||||
return
|
||||
}
|
||||
let context = ModelContext(container)
|
||||
let guides = ReferenceStore(context: context).fetchGuides()
|
||||
let byId = Dictionary(uniqueKeysWithValues: guides.map { ($0.tenseId, $0) })
|
||||
relatedTenses = GuideCrossLinks.tenseIds(forNote: note.id).compactMap { byId[$0] }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -83,6 +103,12 @@ struct GrammarNoteDetailView: View {
|
||||
.background(.fill.tertiary, in: Capsule())
|
||||
}
|
||||
|
||||
if !relatedTenses.isEmpty {
|
||||
relatedTensesSection
|
||||
}
|
||||
|
||||
videoSection
|
||||
|
||||
Divider()
|
||||
|
||||
// Parsed body
|
||||
@@ -107,6 +133,49 @@ struct GrammarNoteDetailView: View {
|
||||
}
|
||||
.navigationTitle(note.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadRelatedTenses)
|
||||
.onChange(of: note.id) { _, _ in loadRelatedTenses() }
|
||||
}
|
||||
|
||||
private var relatedTensesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Used in tenses", systemImage: "clock.arrow.circlepath")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(relatedTenses, id: \.tenseId) { guide in
|
||||
Button {
|
||||
onJumpToTense?(guide)
|
||||
} label: {
|
||||
Text(TenseInfo.find(guide.tenseId)?.english ?? guide.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(.orange.opacity(0.12), in: Capsule())
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var videoSection: some View {
|
||||
if let video = curatedVideo {
|
||||
VideoActionsButtonRow(video: video)
|
||||
} else {
|
||||
Label("No video yet", systemImage: "play.slash")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 10)
|
||||
.background(.fill.quinary, in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,15 +41,20 @@ struct GuideView: View {
|
||||
.navigationTitle("Guide")
|
||||
.task { loadGuides() }
|
||||
.onAppear(perform: loadGuides)
|
||||
.onChange(of: selectedTab) { _, _ in
|
||||
selectedGuide = nil
|
||||
selectedNote = nil
|
||||
.onChange(of: selectedTab) { _, newTab in
|
||||
// Only clear the *other* tab's selection so programmatic
|
||||
// cross-link jumps (chip taps in the detail pane) can keep
|
||||
// their newly-set selection on the destination tab.
|
||||
switch newTab {
|
||||
case .tenses: selectedNote = nil
|
||||
case .grammar: selectedGuide = nil
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
if let guide = selectedGuide {
|
||||
GuideDetailView(guide: guide)
|
||||
GuideDetailView(guide: guide, onJumpToNote: jumpToNote)
|
||||
} else if let note = selectedNote {
|
||||
GrammarNoteDetailView(note: note)
|
||||
GrammarNoteDetailView(note: note, onJumpToTense: jumpToTense)
|
||||
} else {
|
||||
ContentUnavailableView("Select a Topic", systemImage: "book", description: Text("Choose a tense or grammar topic to learn more."))
|
||||
}
|
||||
@@ -79,6 +84,16 @@ struct GuideView: View {
|
||||
GrammarNotesListView(selectedNote: $selectedNote)
|
||||
}
|
||||
|
||||
private func jumpToNote(_ note: GrammarNote) {
|
||||
selectedTab = .grammar
|
||||
selectedNote = note
|
||||
}
|
||||
|
||||
private func jumpToTense(_ guide: TenseGuide) {
|
||||
selectedTab = .tenses
|
||||
selectedGuide = guide
|
||||
}
|
||||
|
||||
private func loadGuides() {
|
||||
// Hit the shared local container directly, bypassing @Environment.
|
||||
guard let container = SharedStore.localContainer else {
|
||||
@@ -127,11 +142,23 @@ private struct TenseRowView: View {
|
||||
|
||||
struct GuideDetailView: View {
|
||||
let guide: TenseGuide
|
||||
var onJumpToNote: ((GrammarNote) -> Void)? = nil
|
||||
@Environment(YouTubeVideoStore.self) private var videoStore
|
||||
|
||||
private var relatedNotes: [GrammarNote] {
|
||||
GuideCrossLinks.noteIds(forTense: guide.tenseId).compactMap { id in
|
||||
GrammarNote.allNotesIncludingGenerated.first { $0.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
private var tenseInfo: TenseInfo? {
|
||||
TenseInfo.find(guide.tenseId)
|
||||
}
|
||||
|
||||
private var curatedVideo: YouTubeVideoStore.VideoEntry? {
|
||||
videoStore.video(forTenseId: guide.tenseId)
|
||||
}
|
||||
|
||||
private var endingTable: TenseEndingTable? {
|
||||
TenseEndingTable.find(guide.tenseId)
|
||||
}
|
||||
@@ -146,6 +173,14 @@ struct GuideDetailView: View {
|
||||
// Header
|
||||
headerSection
|
||||
|
||||
// Related grammar notes — cross-links into the Grammar tab
|
||||
if !relatedNotes.isEmpty {
|
||||
relatedNotesSection
|
||||
}
|
||||
|
||||
// Video section (Issue #21)
|
||||
videoSection
|
||||
|
||||
// Conjugation ending table
|
||||
if let table = endingTable {
|
||||
conjugationTableSection(table)
|
||||
@@ -180,6 +215,51 @@ struct GuideDetailView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
// MARK: - Related grammar notes
|
||||
|
||||
private var relatedNotesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Related grammar", systemImage: "book")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(relatedNotes, id: \.id) { note in
|
||||
Button {
|
||||
onJumpToNote?(note)
|
||||
} label: {
|
||||
Text(note.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(.indigo.opacity(0.12), in: Capsule())
|
||||
.foregroundStyle(.indigo)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Video (Issue #21)
|
||||
|
||||
@ViewBuilder
|
||||
private var videoSection: some View {
|
||||
if let video = curatedVideo {
|
||||
VideoActionsButtonRow(video: video)
|
||||
} else {
|
||||
Label("No video yet", systemImage: "play.slash")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 10)
|
||||
.background(.fill.quinary, in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var headerSection: some View {
|
||||
@@ -450,14 +530,17 @@ struct GuideContent {
|
||||
var spanishLine: String?
|
||||
|
||||
func flushUsage() {
|
||||
if currentUsageNumber > 0 {
|
||||
// Only emit a usage if it has at least one example. This suppresses
|
||||
// the implicit "Usage 1" seeded when we enter an unnumbered
|
||||
// *Usages* block but the body actually has numbered headers below.
|
||||
if currentUsageNumber > 0 && !currentExamples.isEmpty {
|
||||
usages.append(GuideUsage(
|
||||
number: currentUsageNumber,
|
||||
title: currentUsageTitle,
|
||||
examples: currentExamples
|
||||
))
|
||||
currentExamples = []
|
||||
}
|
||||
currentExamples = []
|
||||
}
|
||||
|
||||
for line in lines {
|
||||
@@ -491,6 +574,11 @@ struct GuideContent {
|
||||
let title = String(match.1).replacingOccurrences(of: "*", with: "")
|
||||
if title.lowercased().contains("usage") {
|
||||
inUsages = true
|
||||
// Seed an implicit Usage 1 so content that follows without a
|
||||
// numbered "*1 Title*" header still gets captured. Any numbered
|
||||
// header below will replace this via flushUsage().
|
||||
currentUsageNumber = 1
|
||||
currentUsageTitle = "Usage"
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -563,4 +651,5 @@ struct GuideExample: Identifiable {
|
||||
#Preview {
|
||||
GuideView()
|
||||
.modelContainer(for: TenseGuide.self, inMemory: true)
|
||||
.environment(YouTubeVideoStore())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import AVKit
|
||||
import SharedModels
|
||||
|
||||
/// Three-button row for a curated YouTube video (Issue #21):
|
||||
/// - **Stream** — opens in the YouTube app (falls back to Safari).
|
||||
/// - **Download** — pulls the MP4 via YouTubeKit, shows progress, then enables Play.
|
||||
/// - **Play** — enabled only when the video exists on disk; plays via AVPlayer.
|
||||
///
|
||||
/// Used by both `GuideDetailView` and `GrammarNoteDetailView` to keep the
|
||||
/// video affordances consistent.
|
||||
struct VideoActionsButtonRow: View {
|
||||
let video: YouTubeVideoStore.VideoEntry
|
||||
|
||||
@Environment(\.openURL) private var openURL
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@State private var downloadService = VideoDownloadService.shared
|
||||
@State private var isDownloaded: Bool
|
||||
@State private var playerVideoId: String?
|
||||
@State private var downloadError: String?
|
||||
@State private var confirmDelete: Bool = false
|
||||
|
||||
init(video: YouTubeVideoStore.VideoEntry) {
|
||||
self.video = video
|
||||
self._isDownloaded = State(initialValue: VideoDownloadService.isDownloaded(videoId: video.videoId))
|
||||
}
|
||||
|
||||
private var activeStatus: VideoDownloadService.DownloadStatus? {
|
||||
downloadService.activeDownloads[video.videoId]
|
||||
}
|
||||
|
||||
private var isDownloading: Bool {
|
||||
activeStatus != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(video.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
streamButton
|
||||
downloadButton
|
||||
playButton
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
.fullScreenCover(item: Binding(
|
||||
get: { playerVideoId.map { LocalVideoID(videoId: $0) } },
|
||||
set: { playerVideoId = $0?.videoId }
|
||||
)) { id in
|
||||
LocalVideoPlayerSheet(videoId: id.videoId, title: video.title)
|
||||
}
|
||||
.alert("Download failed", isPresented: .init(
|
||||
get: { downloadError != nil },
|
||||
set: { if !$0 { downloadError = nil } }
|
||||
)) {
|
||||
Button("OK") { downloadError = nil }
|
||||
} message: {
|
||||
Text(downloadError ?? "")
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete this downloaded video?",
|
||||
isPresented: $confirmDelete,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
downloadService.delete(videoId: video.videoId, modelContext: modelContext)
|
||||
isDownloaded = false
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("You can re-download it at any time.")
|
||||
}
|
||||
.onAppear {
|
||||
// Refresh on appear in case the user deleted the file via Settings.
|
||||
isDownloaded = VideoDownloadService.isDownloaded(videoId: video.videoId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Buttons
|
||||
|
||||
private var streamButton: some View {
|
||||
Button {
|
||||
if let url = URL(string: "https://www.youtube.com/watch?v=\(video.videoId)") {
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
openURL(url)
|
||||
}
|
||||
} label: {
|
||||
Label("Stream", systemImage: "play.rectangle.fill")
|
||||
.labelStyle(VideoActionLabelStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
.controlSize(.large)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var downloadButton: some View {
|
||||
// Single slot whose role flips through the download lifecycle:
|
||||
// Download → progress/label (disabled) → Delete.
|
||||
if let status = activeStatus {
|
||||
Button {} label: {
|
||||
VStack(spacing: 4) {
|
||||
if let progress = status.progress {
|
||||
ProgressView(value: progress).frame(width: 56)
|
||||
} else {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
Text(status.label)
|
||||
.font(.caption2.monospacedDigit().weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.blue)
|
||||
.controlSize(.large)
|
||||
.disabled(true)
|
||||
} else if isDownloaded {
|
||||
Button(role: .destructive) {
|
||||
confirmDelete = true
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
.labelStyle(VideoActionLabelStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.red)
|
||||
.controlSize(.large)
|
||||
} else {
|
||||
Button {
|
||||
Task { await startDownload() }
|
||||
} label: {
|
||||
Label("Download", systemImage: "arrow.down.to.line")
|
||||
.labelStyle(VideoActionLabelStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.blue)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
|
||||
private var playButton: some View {
|
||||
Button {
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
playerVideoId = video.videoId
|
||||
} label: {
|
||||
Label("Play", systemImage: "play.fill")
|
||||
.labelStyle(VideoActionLabelStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.green)
|
||||
.controlSize(.large)
|
||||
.disabled(!isDownloaded)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func startDownload() async {
|
||||
do {
|
||||
try await downloadService.download(
|
||||
videoId: video.videoId,
|
||||
title: video.title,
|
||||
into: modelContext
|
||||
)
|
||||
isDownloaded = true
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
} catch {
|
||||
downloadError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper identifiable wrapper so .sheet(item:) can use a plain String
|
||||
|
||||
private struct LocalVideoID: Identifiable {
|
||||
let videoId: String
|
||||
var id: String { videoId }
|
||||
}
|
||||
|
||||
// Stacks the icon above the title so three equal-width buttons fit an iPhone
|
||||
// row without wrapping mid-word.
|
||||
private struct VideoActionLabelStyle: LabelStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
configuration.icon
|
||||
.imageScale(.large)
|
||||
configuration.title
|
||||
.font(.caption2.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Local playback sheet
|
||||
|
||||
struct LocalVideoPlayerSheet: View {
|
||||
let videoId: String
|
||||
let title: String
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var player: AVPlayer
|
||||
|
||||
init(videoId: String, title: String) {
|
||||
self.videoId = videoId
|
||||
self.title = title
|
||||
self._player = State(initialValue: AVPlayer(url: VideoDownloadService.fileURL(for: videoId)))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
VideoPlayer(player: player)
|
||||
.ignoresSafeArea()
|
||||
.onAppear { player.play() }
|
||||
.onDisappear { player.pause() }
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(.black, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ struct OnboardingView: View {
|
||||
|
||||
private func completeOnboarding() {
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
progress.selectedVerbLevel = selectedLevel
|
||||
progress.selectedVerbLevels = [selectedLevel]
|
||||
if progress.enabledTenseIDs.isEmpty {
|
||||
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct BookChapterListView: View {
|
||||
let book: Book
|
||||
|
||||
@Query private var allChapters: [BookChapter]
|
||||
|
||||
init(book: Book) {
|
||||
self.book = book
|
||||
let slug = book.slug
|
||||
_allChapters = Query(
|
||||
filter: #Predicate<BookChapter> { $0.bookSlug == slug },
|
||||
sort: \BookChapter.number
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(allChapters) { chapter in
|
||||
NavigationLink(value: chapter) {
|
||||
HStack(spacing: 12) {
|
||||
Text("\(chapter.number)")
|
||||
.font(.subheadline.weight(.bold).monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 32, alignment: .trailing)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(chapter.title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text("\(chapter.paragraphCount) paragraph\(chapter.paragraphCount == 1 ? "" : "s")")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(book.title.prefix(while: { $0 != ":" }).description)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Route value for pushing the books library. Lets `PracticeView` use a
|
||||
/// value-based link so the entire books navigation chain is consistent —
|
||||
/// mixing a view-based push with value-based pushes deeper in the same
|
||||
/// NavigationStack made pushed screens pop back immediately.
|
||||
enum BooksRoute: Hashable {
|
||||
case library
|
||||
}
|
||||
|
||||
struct BookLibraryView: View {
|
||||
@Query(sort: \Book.title) private var books: [Book]
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if books.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Books",
|
||||
systemImage: "books.vertical",
|
||||
description: Text("Books bundled with the app will appear here.")
|
||||
)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(books) { book in
|
||||
NavigationLink(value: book) {
|
||||
BookCard(book: book)
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Books")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
private struct BookCard: View {
|
||||
let book: Book
|
||||
|
||||
private var accentColor: Color {
|
||||
Color(hex: book.accentColorHex) ?? .indigo
|
||||
}
|
||||
|
||||
private var shortTitle: String {
|
||||
// Trim "Volume X" subtitle if present — most book titles are way too long.
|
||||
if let colon = book.title.firstIndex(of: ":") {
|
||||
return String(book.title[..<colon])
|
||||
}
|
||||
return book.title
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(accentColor.gradient)
|
||||
.frame(width: 48, height: 64)
|
||||
.overlay {
|
||||
Image(systemName: "book.closed.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(shortTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.multilineTextAlignment(.leading)
|
||||
if !book.author.isEmpty {
|
||||
Text(book.author)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("\(book.chapterCount) chapter\(book.chapterCount == 1 ? "" : "s")")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
|
||||
private extension Color {
|
||||
init?(hex: String) {
|
||||
var s = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if s.hasPrefix("#") { s.removeFirst() }
|
||||
guard s.count == 6, let v = UInt32(s, radix: 16) else { return nil }
|
||||
let r = Double((v >> 16) & 0xFF) / 255.0
|
||||
let g = Double((v >> 8) & 0xFF) / 255.0
|
||||
let b = Double(v & 0xFF) / 255.0
|
||||
self = Color(red: r, green: g, blue: b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
import FoundationModels
|
||||
|
||||
struct BookReaderView: View {
|
||||
let chapter: BookChapter
|
||||
|
||||
/// The book this chapter belongs to, resolved by slug — used for the
|
||||
/// pre-computed glossary. A @Query is safe here because the reader is
|
||||
/// built lazily by `navigationDestination`: one instance, when opened.
|
||||
@Query private var bookMatches: [Book]
|
||||
private var book: Book? { bookMatches.first }
|
||||
|
||||
@Environment(DictionaryService.self) private var dictionary
|
||||
@State private var speech = BookSpeechController()
|
||||
@State private var selectedWord: WordAnnotation?
|
||||
@State private var showEnglish = false
|
||||
@State private var showVoicePicker = false
|
||||
@State private var wasReadingBeforeTap = false
|
||||
@State private var lookupCache: [String: WordAnnotation] = [:]
|
||||
/// The book's pre-computed glossary, decoded once on appear.
|
||||
@State private var glossary: [String: WordGloss] = [:]
|
||||
|
||||
@AppStorage("bookReaderVoiceId") private var storedVoiceId: String = ""
|
||||
@AppStorage("bookReaderRate") private var storedRate: Double = 0.45
|
||||
|
||||
init(chapter: BookChapter) {
|
||||
self.chapter = chapter
|
||||
let slug = chapter.bookSlug
|
||||
_bookMatches = Query(filter: #Predicate<Book> { $0.slug == slug })
|
||||
}
|
||||
|
||||
private var paragraphsES: [String] { chapter.paragraphsES() }
|
||||
private var paragraphsEN: [String] { chapter.paragraphsEN() }
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 18) {
|
||||
Text(chapter.title)
|
||||
.font(.title2.bold())
|
||||
.padding(.bottom, 4)
|
||||
.id(-1)
|
||||
|
||||
ForEach(Array(paragraphsES.enumerated()), id: \.offset) { index, paragraph in
|
||||
paragraphView(index: index, paragraph: paragraph)
|
||||
.id(index)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 800)
|
||||
}
|
||||
.onChange(of: speech.currentParagraphIndex) { _, newIndex in
|
||||
guard let newIndex else { return }
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
proxy.scrollTo(newIndex, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Chapter \(chapter.number)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showVoicePicker = true
|
||||
} label: {
|
||||
Image(systemName: "waveform.circle")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
.accessibilityLabel("Voice & speed")
|
||||
|
||||
Button {
|
||||
toggleReadAloud()
|
||||
} label: {
|
||||
Image(systemName: speech.isReading ? "stop.circle.fill" : "play.circle.fill")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(.indigo)
|
||||
}
|
||||
.accessibilityLabel(speech.isReading ? "Stop reading" : "Read aloud")
|
||||
|
||||
Button {
|
||||
withAnimation { showEnglish.toggle() }
|
||||
} label: {
|
||||
Image(systemName: showEnglish ? "character.book.closed.fill.he" : "character.book.closed")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
.accessibilityLabel(showEnglish ? "Show Spanish" : "Show English")
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedWord, onDismiss: handleSheetDismiss) { word in
|
||||
WordDetailSheet(word: word)
|
||||
.presentationDetents([.height(220)])
|
||||
}
|
||||
.sheet(isPresented: $showVoicePicker) {
|
||||
BookVoicePickerSheet(voiceIdentifier: voiceBinding, rate: rateBinding)
|
||||
}
|
||||
.onAppear {
|
||||
speech.voiceIdentifier = storedVoiceId.isEmpty ? nil : storedVoiceId
|
||||
speech.rate = Float(storedRate)
|
||||
if glossary.isEmpty {
|
||||
glossary = book?.glossary() ?? [:]
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
speech.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func paragraphView(index: Int, paragraph: String) -> some View {
|
||||
if showEnglish {
|
||||
Text(translation(for: index))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
TappableParagraph(
|
||||
text: paragraph,
|
||||
highlightedWordIndex: speech.currentParagraphIndex == index ? speech.currentWordIndex : nil
|
||||
) { word in
|
||||
handleTap(word: word, paragraph: paragraph)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func translation(for index: Int) -> String {
|
||||
guard index < paragraphsEN.count else { return "" }
|
||||
let en = paragraphsEN[index]
|
||||
return en.isEmpty ? "[translation unavailable]" : en
|
||||
}
|
||||
|
||||
// MARK: - Read-along controls
|
||||
|
||||
private func toggleReadAloud() {
|
||||
if speech.isReading {
|
||||
speech.stop()
|
||||
} else {
|
||||
// Start from the first non-vocab paragraph at or after the topmost
|
||||
// visible one. For V1 we start from the chapter top — adding
|
||||
// "start from visible paragraph" would need a scroll-position
|
||||
// observer, which isn't worth the complexity yet.
|
||||
speech.start(paragraphs: paragraphsES)
|
||||
}
|
||||
}
|
||||
|
||||
private var voiceBinding: Binding<String?> {
|
||||
Binding(
|
||||
get: { storedVoiceId.isEmpty ? nil : storedVoiceId },
|
||||
set: { newValue in
|
||||
storedVoiceId = newValue ?? ""
|
||||
speech.voiceIdentifier = newValue
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var rateBinding: Binding<Float> {
|
||||
Binding(
|
||||
get: { Float(storedRate) },
|
||||
set: { newValue in
|
||||
storedRate = Double(newValue)
|
||||
speech.rate = newValue
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Word tap → definition
|
||||
|
||||
private func handleTap(word: String, paragraph: String) {
|
||||
let cleaned = cleanWord(word)
|
||||
if cleaned.isEmpty { return }
|
||||
|
||||
// If reading aloud, pause immediately. Remember so we can resume when
|
||||
// the user dismisses the definition sheet.
|
||||
if speech.isReading, !speech.isPaused {
|
||||
speech.pause()
|
||||
wasReadingBeforeTap = true
|
||||
}
|
||||
|
||||
// Fall-through chain, best source first. Whichever resource answers,
|
||||
// the popup names it so a curated glossary hit reads differently from
|
||||
// a best-effort on-device LLM guess.
|
||||
if let cached = lookupCache[cleaned] {
|
||||
selectedWord = cached
|
||||
return
|
||||
}
|
||||
if let gloss = glossary[cleaned] {
|
||||
let annotation = WordAnnotation(
|
||||
word: cleaned,
|
||||
baseForm: gloss.baseForm,
|
||||
english: gloss.english,
|
||||
partOfSpeech: gloss.partOfSpeech,
|
||||
source: "Book glossary"
|
||||
)
|
||||
lookupCache[cleaned] = annotation
|
||||
selectedWord = annotation
|
||||
return
|
||||
}
|
||||
if let entry = dictionary.lookup(cleaned) {
|
||||
let annotation = WordAnnotation(
|
||||
word: cleaned,
|
||||
baseForm: entry.baseForm,
|
||||
english: entry.english,
|
||||
partOfSpeech: entry.partOfSpeech,
|
||||
source: "Dictionary"
|
||||
)
|
||||
lookupCache[cleaned] = annotation
|
||||
selectedWord = annotation
|
||||
return
|
||||
}
|
||||
selectedWord = WordAnnotation(word: cleaned, baseForm: cleaned, english: "Looking up...", partOfSpeech: "")
|
||||
Task {
|
||||
do {
|
||||
var annotation = try await WordLookup.lookup(word: cleaned, inContext: paragraph)
|
||||
annotation.source = "AI guess"
|
||||
lookupCache[cleaned] = annotation
|
||||
selectedWord = annotation
|
||||
} catch {
|
||||
selectedWord = WordAnnotation(word: cleaned, baseForm: cleaned, english: "Lookup unavailable", partOfSpeech: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSheetDismiss() {
|
||||
guard wasReadingBeforeTap else { return }
|
||||
wasReadingBeforeTap = false
|
||||
speech.resume()
|
||||
}
|
||||
|
||||
private func cleanWord(_ word: String) -> String {
|
||||
word.lowercased()
|
||||
.trimmingCharacters(in: .punctuationCharacters)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tappable paragraph
|
||||
|
||||
private struct TappableParagraph: View {
|
||||
let text: String
|
||||
let highlightedWordIndex: Int?
|
||||
let onTap: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
let words = text.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
|
||||
FlowLayout(spacing: 0) {
|
||||
ForEach(Array(words.enumerated()), id: \.offset) { idx, word in
|
||||
WordButton(word: word, isHighlighted: idx == highlightedWordIndex, onTap: onTap)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WordButton: View {
|
||||
let word: String
|
||||
let isHighlighted: Bool
|
||||
let onTap: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
onTap(word)
|
||||
} label: {
|
||||
Text(word + " ")
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
.padding(.horizontal, isHighlighted ? 2 : 0)
|
||||
.padding(.vertical, 1)
|
||||
.background(
|
||||
isHighlighted
|
||||
? Color.yellow.opacity(0.35)
|
||||
: Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 4)
|
||||
)
|
||||
.animation(.easeInOut(duration: 0.15), value: isHighlighted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Flow layout
|
||||
|
||||
private struct FlowLayout: Layout {
|
||||
var spacing: CGFloat = 0
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||
var height: CGFloat = 0
|
||||
for row in rows {
|
||||
height += row.map { $0.height }.max() ?? 0
|
||||
}
|
||||
height += CGFloat(max(0, rows.count - 1)) * spacing
|
||||
return CGSize(width: proposal.width ?? 0, height: height)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||
var y = bounds.minY
|
||||
var subviewIndex = 0
|
||||
for row in rows {
|
||||
var x = bounds.minX
|
||||
let rowHeight = row.map { $0.height }.max() ?? 0
|
||||
for size in row {
|
||||
subviews[subviewIndex].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||
x += size.width
|
||||
subviewIndex += 1
|
||||
}
|
||||
y += rowHeight + spacing
|
||||
}
|
||||
}
|
||||
|
||||
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
var rows: [[CGSize]] = [[]]
|
||||
var currentWidth: CGFloat = 0
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if currentWidth + size.width > maxWidth && !rows[rows.count - 1].isEmpty {
|
||||
rows.append([])
|
||||
currentWidth = 0
|
||||
}
|
||||
rows[rows.count - 1].append(size)
|
||||
currentWidth += size.width
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Word detail sheet
|
||||
|
||||
private struct WordDetailSheet: View {
|
||||
let word: WordAnnotation
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
HStack {
|
||||
Text(word.word)
|
||||
.font(.title2.bold())
|
||||
Spacer()
|
||||
if !word.partOfSpeech.isEmpty {
|
||||
Text(word.partOfSpeech)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.fill.tertiary, in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if word.english == "Looking up..." {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Looking up word...")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !word.baseForm.isEmpty && word.baseForm != word.word {
|
||||
HStack {
|
||||
Text("Base form:")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(word.baseForm)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.italic()
|
||||
}
|
||||
}
|
||||
if !word.english.isEmpty {
|
||||
HStack {
|
||||
Text("English:")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(word.english)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if !word.source.isEmpty {
|
||||
Text(sourceLabel)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var sourceLabel: String {
|
||||
word.source == "AI guess"
|
||||
? "AI guess · on-device estimate, may be approximate"
|
||||
: "Source: \(word.source)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - On-demand word lookup (matches StoryReaderView's WordLookup)
|
||||
|
||||
@MainActor
|
||||
private enum WordLookup {
|
||||
@Generable
|
||||
struct WordInfo {
|
||||
@Guide(description: "The dictionary base form (infinitive for verbs, singular for nouns)")
|
||||
var baseForm: String
|
||||
@Guide(description: "English translation")
|
||||
var english: String
|
||||
@Guide(description: "Part of speech: verb, noun, adjective, adverb, preposition, conjunction, article, pronoun, or other")
|
||||
var partOfSpeech: String
|
||||
}
|
||||
|
||||
static func lookup(word: String, inContext sentence: String) async throws -> WordAnnotation {
|
||||
let session = LanguageModelSession(instructions: """
|
||||
You are a Spanish dictionary. Given a word and the sentence it appears in, \
|
||||
provide its base form, English translation, and part of speech.
|
||||
""")
|
||||
let response = try await session.respond(
|
||||
to: "Word: \"\(word)\" in sentence: \"\(sentence)\"",
|
||||
generating: WordInfo.self
|
||||
)
|
||||
let info = response.content
|
||||
return WordAnnotation(
|
||||
word: word,
|
||||
baseForm: info.baseForm,
|
||||
english: info.english,
|
||||
partOfSpeech: info.partOfSpeech
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
/// Voice + speed picker shown from the book reader's toolbar. Lists Spanish
|
||||
/// voices currently installed on the device grouped by quality, and offers a
|
||||
/// shortcut to the iOS Settings app where the user can download premium voices
|
||||
/// (no public deep-link to the Accessibility section exists, so we open the
|
||||
/// app's own Settings page with a hint).
|
||||
struct BookVoicePickerSheet: View {
|
||||
@Binding var voiceIdentifier: String?
|
||||
@Binding var rate: Float
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private struct VoiceGroup: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let voices: [AVSpeechSynthesisVoice]
|
||||
}
|
||||
|
||||
private var groups: [VoiceGroup] {
|
||||
let all = AVSpeechSynthesisVoice.speechVoices()
|
||||
.filter { $0.language.hasPrefix("es") }
|
||||
let buckets: [(String, AVSpeechSynthesisVoiceQuality)] = [
|
||||
("Premium", .premium),
|
||||
("Enhanced", .enhanced),
|
||||
("Default", .default),
|
||||
]
|
||||
return buckets.compactMap { (title, quality) in
|
||||
let voices = all
|
||||
.filter { $0.quality == quality }
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.language != rhs.language { return lhs.language < rhs.language }
|
||||
return lhs.name < rhs.name
|
||||
}
|
||||
return voices.isEmpty ? nil : VoiceGroup(id: title, title: title, voices: voices)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Speed") {
|
||||
Picker("Speed", selection: $rate) {
|
||||
Text("Slow").tag(Float(0.40))
|
||||
Text("Normal").tag(Float(0.50))
|
||||
Text("Fast").tag(Float(0.55))
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
if groups.isEmpty {
|
||||
Section {
|
||||
ContentUnavailableView(
|
||||
"No Spanish voices",
|
||||
systemImage: "person.wave.2",
|
||||
description: Text("Install a Spanish voice in Settings → Accessibility → Spoken Content → Voices.")
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ForEach(groups) { group in
|
||||
Section(group.title) {
|
||||
ForEach(group.voices, id: \.identifier) { voice in
|
||||
voiceRow(voice)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
openSettings()
|
||||
} label: {
|
||||
Label("Download more voices…", systemImage: "arrow.down.circle")
|
||||
}
|
||||
} footer: {
|
||||
Text("Opens Settings. Navigate to Accessibility → Spoken Content → Voices → Spanish to install premium or enhanced voices.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Read aloud")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func voiceRow(_ voice: AVSpeechSynthesisVoice) -> some View {
|
||||
Button {
|
||||
voiceIdentifier = voice.identifier
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(voice.name)
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
Text(voice.language)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if voice.identifier == voiceIdentifier {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.indigo)
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
|
||||
private func openSettings() {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,27 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
import FoundationModels
|
||||
|
||||
@Generable
|
||||
private struct ChatWordInfo {
|
||||
@Guide(description: "Dictionary base form") var baseForm: String
|
||||
@Guide(description: "English translation") var english: String
|
||||
@Guide(description: "Part of speech") var partOfSpeech: String
|
||||
}
|
||||
|
||||
struct ChatView: View {
|
||||
let conversation: Conversation
|
||||
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(DictionaryService.self) private var dictionary
|
||||
@State private var service = ConversationService()
|
||||
@State private var messages: [ChatMessage] = []
|
||||
@State private var inputText = ""
|
||||
@State private var errorMessage: String?
|
||||
@State private var hasStarted = false
|
||||
@State private var selectedWord: WordAnnotation?
|
||||
@State private var lookupCache: [String: WordAnnotation] = [:]
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@@ -21,8 +32,10 @@ struct ChatView: View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(messages) { message in
|
||||
ChatBubble(message: message)
|
||||
.id(message.id)
|
||||
ChatBubble(message: message, dictionary: dictionary, lookupCache: $lookupCache) { word in
|
||||
selectedWord = word
|
||||
}
|
||||
.id(message.id)
|
||||
}
|
||||
|
||||
if service.isResponding {
|
||||
@@ -68,6 +81,10 @@ struct ChatView: View {
|
||||
}
|
||||
.navigationTitle(conversation.scenario)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(item: $selectedWord) { word in
|
||||
ChatWordDetailSheet(word: word)
|
||||
.presentationDetents([.height(200)])
|
||||
}
|
||||
.alert("Error", isPresented: .init(
|
||||
get: { errorMessage != nil },
|
||||
set: { if !$0 { errorMessage = nil } }
|
||||
@@ -102,6 +119,7 @@ struct ChatView: View {
|
||||
messages = conversation.decodedMessages
|
||||
inputText = ""
|
||||
try? cloudContext.save()
|
||||
ReviewStore.recordActivity(context: cloudContext)
|
||||
|
||||
Task {
|
||||
do {
|
||||
@@ -121,6 +139,9 @@ struct ChatView: View {
|
||||
|
||||
private struct ChatBubble: View {
|
||||
let message: ChatMessage
|
||||
let dictionary: DictionaryService
|
||||
@Binding var lookupCache: [String: WordAnnotation]
|
||||
let onWordTap: (WordAnnotation) -> Void
|
||||
|
||||
private var isUser: Bool { message.role == "user" }
|
||||
|
||||
@@ -129,11 +150,15 @@ private struct ChatBubble: View {
|
||||
if isUser { Spacer(minLength: 60) }
|
||||
|
||||
VStack(alignment: isUser ? .trailing : .leading, spacing: 4) {
|
||||
Text(message.content)
|
||||
.font(.body)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(isUser ? AnyShapeStyle(.green.opacity(0.2)) : AnyShapeStyle(.fill.quaternary), in: RoundedRectangle(cornerRadius: 16))
|
||||
if isUser {
|
||||
Text(message.content)
|
||||
.font(.body)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(.green.opacity(0.2), in: RoundedRectangle(cornerRadius: 16))
|
||||
} else {
|
||||
tappableBubble
|
||||
}
|
||||
|
||||
if let correction = message.correction, !correction.isEmpty {
|
||||
Text(correction)
|
||||
@@ -147,4 +172,179 @@ private struct ChatBubble: View {
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private var tappableBubble: some View {
|
||||
let words = message.content.components(separatedBy: " ")
|
||||
return ChatFlowLayout(spacing: 0) {
|
||||
ForEach(Array(words.enumerated()), id: \.offset) { _, word in
|
||||
ChatWordButton(word: word, dictionary: dictionary, cache: lookupCache) { annotation in
|
||||
if annotation.english.isEmpty {
|
||||
lookupWordAsync(annotation.word)
|
||||
} else {
|
||||
onWordTap(annotation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private func lookupWordAsync(_ word: String) {
|
||||
// Try dictionary first
|
||||
if let entry = dictionary.lookup(word) {
|
||||
let annotation = WordAnnotation(word: word, baseForm: entry.baseForm, english: entry.english, partOfSpeech: entry.partOfSpeech)
|
||||
lookupCache[word] = annotation
|
||||
onWordTap(annotation)
|
||||
return
|
||||
}
|
||||
|
||||
// Show loading then AI lookup
|
||||
onWordTap(WordAnnotation(word: word, baseForm: word, english: "Looking up...", partOfSpeech: ""))
|
||||
|
||||
Task {
|
||||
do {
|
||||
let session = LanguageModelSession(instructions: "You are a Spanish dictionary. Provide base form, English translation, and part of speech.")
|
||||
let response = try await session.respond(to: "Word: \"\(word)\"", generating: ChatWordInfo.self)
|
||||
let info = response.content
|
||||
let annotation = WordAnnotation(word: word, baseForm: info.baseForm, english: info.english, partOfSpeech: info.partOfSpeech)
|
||||
lookupCache[word] = annotation
|
||||
onWordTap(annotation)
|
||||
} catch {
|
||||
onWordTap(WordAnnotation(word: word, baseForm: word, english: "Lookup unavailable", partOfSpeech: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chat Word Button
|
||||
|
||||
private struct ChatWordButton: View {
|
||||
let word: String
|
||||
let dictionary: DictionaryService
|
||||
let cache: [String: WordAnnotation]
|
||||
let onTap: (WordAnnotation) -> Void
|
||||
|
||||
private var cleaned: String {
|
||||
word.lowercased()
|
||||
.trimmingCharacters(in: .punctuationCharacters)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
|
||||
private var annotation: WordAnnotation? {
|
||||
if let cached = cache[cleaned] { return cached }
|
||||
if let entry = dictionary.lookup(cleaned) {
|
||||
return WordAnnotation(word: cleaned, baseForm: entry.baseForm, english: entry.english, partOfSpeech: entry.partOfSpeech)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
onTap(annotation ?? WordAnnotation(word: cleaned, baseForm: cleaned, english: "", partOfSpeech: ""))
|
||||
} label: {
|
||||
Text(word + " ")
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
.underline(annotation != nil, color: .teal.opacity(0.3))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Word Detail Sheet
|
||||
|
||||
private struct ChatWordDetailSheet: View {
|
||||
let word: WordAnnotation
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
HStack {
|
||||
Text(word.word)
|
||||
.font(.title2.bold())
|
||||
Spacer()
|
||||
if !word.partOfSpeech.isEmpty {
|
||||
Text(word.partOfSpeech)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.fill.tertiary, in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if word.english == "Looking up..." {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Looking up word...")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !word.baseForm.isEmpty && word.baseForm != word.word {
|
||||
HStack {
|
||||
Text("Base form:")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(word.baseForm)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.italic()
|
||||
}
|
||||
}
|
||||
if !word.english.isEmpty {
|
||||
HStack {
|
||||
Text("English:")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(word.english)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chat Flow Layout
|
||||
|
||||
private struct ChatFlowLayout: Layout {
|
||||
var spacing: CGFloat = 0
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||
var height: CGFloat = 0
|
||||
for row in rows { height += row.map { $0.height }.max() ?? 0 }
|
||||
height += CGFloat(max(0, rows.count - 1)) * spacing
|
||||
return CGSize(width: proposal.width ?? 0, height: height)
|
||||
}
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||
var y = bounds.minY; var idx = 0
|
||||
for row in rows {
|
||||
var x = bounds.minX; let rh = row.map { $0.height }.max() ?? 0
|
||||
for size in row {
|
||||
subviews[idx].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||
x += size.width; idx += 1
|
||||
}
|
||||
y += rh + spacing
|
||||
}
|
||||
}
|
||||
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
|
||||
let mw = proposal.width ?? .infinity; var rows: [[CGSize]] = [[]]; var cw: CGFloat = 0
|
||||
for sv in subviews {
|
||||
let s = sv.sizeThatFits(.unspecified)
|
||||
if cw + s.width > mw && !rows[rows.count - 1].isEmpty { rows.append([]); cw = 0 }
|
||||
rows[rows.count - 1].append(s); cw += s.width
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ struct ClozeView: View {
|
||||
currentIndex += 1
|
||||
selectedOption = nil
|
||||
} else {
|
||||
ReviewStore.recordActivity(context: cloudContext)
|
||||
withAnimation { isFinished = true }
|
||||
}
|
||||
} label: {
|
||||
|
||||
@@ -20,6 +20,7 @@ struct FullTableView: View {
|
||||
@State private var useHandwriting = false
|
||||
@State private var sessionCount = 0
|
||||
@State private var sessionCorrect = 0
|
||||
@State private var noEligibleVerbs = false
|
||||
|
||||
// Handwriting state per field
|
||||
@State private var drawings: [PKDrawing] = Array(repeating: PKDrawing(), count: 6)
|
||||
@@ -53,35 +54,39 @@ struct FullTableView: View {
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 32) {
|
||||
// Header
|
||||
if let verb = currentVerb, let tense = currentTense {
|
||||
headerSection(verb: verb, tense: tense)
|
||||
}
|
||||
|
||||
// Input mode toggle
|
||||
HStack {
|
||||
Picker("Input", selection: $useHandwriting) {
|
||||
Label("Keyboard", systemImage: "keyboard").tag(false)
|
||||
Label("Pencil", systemImage: "pencil.and.outline").tag(true)
|
||||
if noEligibleVerbs {
|
||||
emptyPoolError
|
||||
} else {
|
||||
VStack(spacing: 32) {
|
||||
// Header
|
||||
if let verb = currentVerb, let tense = currentTense {
|
||||
headerSection(verb: verb, tense: tense)
|
||||
}
|
||||
|
||||
// Input mode toggle
|
||||
HStack {
|
||||
Picker("Input", selection: $useHandwriting) {
|
||||
Label("Keyboard", systemImage: "keyboard").tag(false)
|
||||
Label("Pencil", systemImage: "pencil.and.outline").tag(true)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Input fields
|
||||
inputSection
|
||||
|
||||
// Check / Next button
|
||||
actionButton
|
||||
|
||||
// Score
|
||||
if sessionCount > 0 {
|
||||
scoreSection
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Input fields
|
||||
inputSection
|
||||
|
||||
// Check / Next button
|
||||
actionButton
|
||||
|
||||
// Score
|
||||
if sessionCount > 0 {
|
||||
scoreSection
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer()
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer()
|
||||
}
|
||||
.navigationTitle("Full Table")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -91,6 +96,22 @@ struct FullTableView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty pool error
|
||||
|
||||
private var emptyPoolError: some View {
|
||||
VStack(spacing: 16) {
|
||||
ContentUnavailableView(
|
||||
"No regular verbs available",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text(
|
||||
"None of the selected tenses have any fully-regular verbs in the current settings. Enable more tenses, or turn off the Reflexive-only toggle in Settings."
|
||||
)
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer()
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private func headerSection(verb: Verb, tense: TenseInfo) -> some View {
|
||||
@@ -243,15 +264,27 @@ struct FullTableView: View {
|
||||
results = Array(repeating: nil, count: 6)
|
||||
correctForms = []
|
||||
drawings = Array(repeating: PKDrawing(), count: 6)
|
||||
let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext)
|
||||
guard let prompt = service.randomFullTablePrompt() else {
|
||||
let service = PracticeSessionService(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext,
|
||||
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
|
||||
)
|
||||
guard let prompt = service.randomFullTablePrompt(
|
||||
previousTenseId: currentTense?.id,
|
||||
previousEnding: currentVerb?.ending
|
||||
) else {
|
||||
// Genuinely no eligible (verb, tense) combo. Surface a clear error
|
||||
// instead of a blank screen — the previous behaviour silently
|
||||
// rendered an empty header and inputs.
|
||||
currentVerb = nil
|
||||
currentTense = nil
|
||||
userAnswers = Array(repeating: "", count: 6)
|
||||
focusedField = nil
|
||||
noEligibleVerbs = true
|
||||
return
|
||||
}
|
||||
|
||||
noEligibleVerbs = false
|
||||
currentVerb = prompt.verb
|
||||
currentTense = prompt.tenseInfo
|
||||
correctForms = prompt.forms
|
||||
@@ -312,7 +345,11 @@ struct FullTableView: View {
|
||||
if allCorrect { sessionCorrect += 1 }
|
||||
|
||||
if let verb = currentVerb, let tense = currentTense {
|
||||
let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext)
|
||||
let service = PracticeSessionService(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext,
|
||||
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
|
||||
)
|
||||
let reviewResults = Dictionary(uniqueKeysWithValues: personsToShow.map { ($0.index, results[$0.index] == true) })
|
||||
_ = service.recordFullTableReview(verbId: verb.id, tenseId: tense.id, results: reviewResults)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import SwiftData
|
||||
|
||||
struct ListeningView: View {
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
@State private var pronunciation = PronunciationService()
|
||||
@State private var speechService = SpeechService()
|
||||
|
||||
@@ -122,6 +124,7 @@ struct ListeningView: View {
|
||||
Button {
|
||||
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput)
|
||||
if result.score >= 0.7 { correctCount += 1 }
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
withAnimation { isRevealed = true }
|
||||
} label: {
|
||||
Text("Check")
|
||||
@@ -164,6 +167,7 @@ struct ListeningView: View {
|
||||
score = result.score
|
||||
wordMatches = result.matches
|
||||
if result.score >= 0.7 { correctCount += 1 }
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
withAnimation { isRevealed = true }
|
||||
} else {
|
||||
pronunciation.startRecording()
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
|
||||
struct LyricsReaderView: View {
|
||||
let song: SavedSong
|
||||
|
||||
@Environment(DictionaryService.self) private var dictionary
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
@State private var selectedWord: LyricsWordLookup?
|
||||
@State private var lookupCache: [String: LyricsWordLookup] = [:]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
@@ -15,6 +22,10 @@ struct LyricsReaderView: View {
|
||||
}
|
||||
.navigationTitle(song.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(item: $selectedWord) { word in
|
||||
LyricsWordDetailSheet(word: word)
|
||||
.presentationDetents([.height(260)])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
@@ -56,15 +67,6 @@ struct LyricsReaderView: View {
|
||||
let spanishLines = song.lyricsES.components(separatedBy: "\n")
|
||||
let englishLines = song.lyricsEN.components(separatedBy: "\n")
|
||||
let lineCount = max(spanishLines.count, englishLines.count)
|
||||
let _ = {
|
||||
print("[LyricsReader] ES lines: \(spanishLines.count), EN lines: \(englishLines.count), rendering: \(lineCount)")
|
||||
for i in 0..<min(15, lineCount) {
|
||||
let es = i < spanishLines.count ? spanishLines[i] : "(none)"
|
||||
let en = i < englishLines.count ? englishLines[i] : "(none)"
|
||||
print(" [\(i)] ES: \(es.isEmpty ? "(blank)" : es)")
|
||||
print(" EN: \(en.isEmpty ? "(blank)" : en)")
|
||||
}
|
||||
}()
|
||||
|
||||
return VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(0..<lineCount, id: \.self) { index in
|
||||
@@ -78,8 +80,7 @@ struct LyricsReaderView: View {
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if !es.isEmpty {
|
||||
Text(es)
|
||||
.font(.body.weight(.medium))
|
||||
spanishLine(es)
|
||||
}
|
||||
if !en.isEmpty {
|
||||
Text(en)
|
||||
@@ -94,4 +95,184 @@ struct LyricsReaderView: View {
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private func spanishLine(_ line: String) -> some View {
|
||||
let tokens = line.components(separatedBy: " ")
|
||||
return LyricsFlowLayout(spacing: 0) {
|
||||
ForEach(Array(tokens.enumerated()), id: \.offset) { _, token in
|
||||
LyricsWordView(token: token, lookup: makeLookup(for: token)) { word in
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
selectedWord = word
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lookup
|
||||
|
||||
private func makeLookup(for rawToken: String) -> LyricsWordLookup? {
|
||||
let cleaned = rawToken.lowercased()
|
||||
.trimmingCharacters(in: .punctuationCharacters)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
|
||||
if let cached = lookupCache[cleaned] { return cached }
|
||||
guard let entry = dictionary.lookup(cleaned) else { return nil }
|
||||
|
||||
let displayWord = rawToken
|
||||
.trimmingCharacters(in: .punctuationCharacters)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
let tenseDisplay = entry.tenseId.flatMap { TenseInfo.find($0)?.english }
|
||||
|
||||
let lookup = LyricsWordLookup(
|
||||
word: displayWord.isEmpty ? entry.word : displayWord,
|
||||
baseForm: entry.baseForm,
|
||||
english: entry.english,
|
||||
partOfSpeech: entry.partOfSpeech,
|
||||
tenseDisplay: tenseDisplay,
|
||||
person: entry.person
|
||||
)
|
||||
lookupCache[cleaned] = lookup
|
||||
return lookup
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Word Lookup Model
|
||||
|
||||
private struct LyricsWordLookup: Identifiable, Hashable {
|
||||
let word: String
|
||||
let baseForm: String
|
||||
let english: String
|
||||
let partOfSpeech: String
|
||||
let tenseDisplay: String?
|
||||
let person: String?
|
||||
|
||||
var id: String { word }
|
||||
}
|
||||
|
||||
// MARK: - Word View
|
||||
|
||||
private struct LyricsWordView: View {
|
||||
let token: String
|
||||
let lookup: LyricsWordLookup?
|
||||
let onLookup: (LyricsWordLookup) -> Void
|
||||
|
||||
var body: some View {
|
||||
Text(token + " ")
|
||||
.font(.body.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
.underline(lookup != nil, color: .teal.opacity(0.35))
|
||||
.contentShape(Rectangle())
|
||||
.onLongPressGesture(minimumDuration: 0.35) {
|
||||
if let lookup {
|
||||
onLookup(lookup)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Detail Sheet
|
||||
|
||||
private struct LyricsWordDetailSheet: View {
|
||||
let word: LyricsWordLookup
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Text(word.word)
|
||||
.font(.title2.bold())
|
||||
Spacer()
|
||||
if !word.partOfSpeech.isEmpty {
|
||||
Text(word.partOfSpeech)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.fill.tertiary, in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if !word.baseForm.isEmpty && word.baseForm.lowercased() != word.word.lowercased() {
|
||||
detailRow(label: "Base form", value: word.baseForm, italic: true)
|
||||
}
|
||||
|
||||
if !word.english.isEmpty {
|
||||
detailRow(label: "English", value: word.english)
|
||||
}
|
||||
|
||||
if let tenseDisplay = word.tenseDisplay {
|
||||
let personSuffix = (word.person?.isEmpty == false) ? " · \(word.person!)" : ""
|
||||
detailRow(label: "Tense", value: tenseDisplay + personSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func detailRow(label: String, value: String, italic: Bool = false) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text("\(label):")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 86, alignment: .leading)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.italic(italic)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Flow Layout
|
||||
|
||||
private struct LyricsFlowLayout: Layout {
|
||||
var spacing: CGFloat = 0
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||
var height: CGFloat = 0
|
||||
for row in rows { height += row.map { $0.height }.max() ?? 0 }
|
||||
height += CGFloat(max(0, rows.count - 1)) * spacing
|
||||
return CGSize(width: proposal.width ?? 0, height: height)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||
var y = bounds.minY
|
||||
var idx = 0
|
||||
for row in rows {
|
||||
var x = bounds.minX
|
||||
let rh = row.map { $0.height }.max() ?? 0
|
||||
for size in row {
|
||||
subviews[idx].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||
x += size.width
|
||||
idx += 1
|
||||
}
|
||||
y += rh + spacing
|
||||
}
|
||||
}
|
||||
|
||||
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
|
||||
let mw = proposal.width ?? .infinity
|
||||
var rows: [[CGSize]] = [[]]
|
||||
var cw: CGFloat = 0
|
||||
for sv in subviews {
|
||||
let s = sv.sizeThatFits(.unspecified)
|
||||
if cw + s.width > mw && !rows[rows.count - 1].isEmpty {
|
||||
rows.append([])
|
||||
cw = 0
|
||||
}
|
||||
rows[rows.count - 1].append(s)
|
||||
cw += s.width
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,19 @@ struct PracticeView: View {
|
||||
practiceHomeView
|
||||
}
|
||||
}
|
||||
// Book navigation is value-based and declared once here, at the
|
||||
// stack root. Eager `NavigationLink { destination }` forms inside
|
||||
// the List/LazyVStack of the book screens caused an infinite
|
||||
// render loop; value-based links build destinations lazily.
|
||||
.navigationDestination(for: BooksRoute.self) { _ in
|
||||
BookLibraryView()
|
||||
}
|
||||
.navigationDestination(for: Book.self) { book in
|
||||
BookChapterListView(book: book)
|
||||
}
|
||||
.navigationDestination(for: BookChapter.self) { chapter in
|
||||
BookReaderView(chapter: chapter)
|
||||
}
|
||||
.navigationTitle("Practice")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadProgress)
|
||||
@@ -74,12 +87,10 @@ struct PracticeView: View {
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// Mode selection
|
||||
VStack(spacing: 12) {
|
||||
Text("Choose a Mode")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// === Section: Conjugation ===
|
||||
sectionHeader("Conjugation")
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ForEach(PracticeMode.allCases) { mode in
|
||||
ModeButton(mode: mode) {
|
||||
viewModel.practiceMode = mode
|
||||
@@ -98,6 +109,15 @@ struct PracticeView: View {
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
conjugationFocusButtons
|
||||
|
||||
// === Section: Vocabulary ===
|
||||
sectionHeader("Vocabulary")
|
||||
vocabSection
|
||||
|
||||
// === Section: Reading ===
|
||||
sectionHeader("Reading")
|
||||
|
||||
// Lyrics
|
||||
NavigationLink {
|
||||
LyricsLibraryView()
|
||||
@@ -253,166 +273,33 @@ struct PracticeView: View {
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal)
|
||||
|
||||
// Quick Actions
|
||||
VStack(spacing: 12) {
|
||||
Text("Quick Actions")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// Books
|
||||
NavigationLink(value: BooksRoute.library) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "books.vertical.fill")
|
||||
.font(.title3)
|
||||
.frame(width: 36)
|
||||
.foregroundStyle(.indigo)
|
||||
|
||||
// Vocab review
|
||||
NavigationLink {
|
||||
VocabReviewView()
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "rectangle.stack.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.teal)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Vocab Review")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Review due vocabulary cards")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
let dueCount = VocabReviewView.dueCount(context: cloudModelContext)
|
||||
if dueCount > 0 {
|
||||
Text("\(dueCount)")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(.teal, in: Capsule())
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Books")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Read full-length books with tap-to-define")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Common tenses focus
|
||||
Button {
|
||||
viewModel.practiceMode = .flashcard
|
||||
viewModel.focusMode = .commonTenses
|
||||
viewModel.sessionCorrect = 0
|
||||
viewModel.sessionTotal = 0
|
||||
viewModel.loadNextCard(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
withAnimation { isPracticing = true }
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.orange)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Common Tenses")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Practice the 6 most essential tenses")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Weak verbs focus
|
||||
Button {
|
||||
viewModel.practiceMode = .flashcard
|
||||
viewModel.focusMode = .weakVerbs
|
||||
viewModel.sessionCorrect = 0
|
||||
viewModel.sessionTotal = 0
|
||||
viewModel.loadNextCard(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
withAnimation { isPracticing = true }
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.red)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Weak Verbs")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Focus on verbs you struggle with")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Irregularity drills
|
||||
Menu {
|
||||
Button("Spelling Changes (c→qu, z→c, ...)") {
|
||||
startIrregularityDrill(.spelling)
|
||||
}
|
||||
Button("Stem Changes (o→ue, e→ie, ...)") {
|
||||
startIrregularityDrill(.stemChange)
|
||||
}
|
||||
Button("Unique Irregulars (ser, ir, ...)") {
|
||||
startIrregularityDrill(.uniqueIrregular)
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "wand.and.stars")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.purple)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Irregularity Drills")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Practice by irregularity type")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal)
|
||||
|
||||
// Session stats summary
|
||||
@@ -442,6 +329,172 @@ struct PracticeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Section header
|
||||
|
||||
private func sectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// MARK: - Conjugation focus buttons (Common Tenses / Weak Verbs / Irregularity)
|
||||
|
||||
private var conjugationFocusButtons: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Common Tenses
|
||||
Button {
|
||||
viewModel.practiceMode = .flashcard
|
||||
viewModel.focusMode = .commonTenses
|
||||
viewModel.sessionCorrect = 0
|
||||
viewModel.sessionTotal = 0
|
||||
viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext)
|
||||
withAnimation { isPracticing = true }
|
||||
} label: {
|
||||
practiceRowLabel(icon: "star.fill", color: .orange,
|
||||
title: "Common Tenses",
|
||||
subtitle: "Practice the 6 most essential tenses")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Weak Verbs
|
||||
Button {
|
||||
viewModel.practiceMode = .flashcard
|
||||
viewModel.focusMode = .weakVerbs
|
||||
viewModel.sessionCorrect = 0
|
||||
viewModel.sessionTotal = 0
|
||||
viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext)
|
||||
withAnimation { isPracticing = true }
|
||||
} label: {
|
||||
practiceRowLabel(icon: "exclamationmark.triangle", color: .red,
|
||||
title: "Weak Verbs",
|
||||
subtitle: "Focus on verbs you struggle with")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Irregularity drills
|
||||
Menu {
|
||||
Button("Spelling Changes (c→qu, z→c, ...)") { startIrregularityDrill(.spelling) }
|
||||
Button("Stem Changes (o→ue, e→ie, ...)") { startIrregularityDrill(.stemChange) }
|
||||
Button("Unique Irregulars (ser, ir, ...)") { startIrregularityDrill(.uniqueIrregular) }
|
||||
} label: {
|
||||
practiceRowLabel(icon: "wand.and.stars", color: .purple,
|
||||
title: "Irregularity Drills",
|
||||
subtitle: "Practice by irregularity type")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// MARK: - Vocabulary section
|
||||
|
||||
private var vocabSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Vocab Flashcards (verb pool, filtered by Settings levels)
|
||||
NavigationLink {
|
||||
VocabFlashcardPracticeView()
|
||||
} label: {
|
||||
practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .purple,
|
||||
title: "Vocab Flashcards",
|
||||
subtitle: "Verb meaning → infinitive recall")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Vocab Multiple Choice (same verb pool)
|
||||
NavigationLink {
|
||||
VocabMultipleChoicePracticeView()
|
||||
} label: {
|
||||
practiceRowLabel(icon: "checklist", color: .purple,
|
||||
title: "Vocab Multiple Choice",
|
||||
subtitle: "Pick the Spanish infinitive from 4 options")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Review Learned — consolidation cram over already-studied verbs
|
||||
NavigationLink {
|
||||
VocabFlashcardPracticeView(kind: .reviewLearned)
|
||||
} label: {
|
||||
practiceRowLabel(icon: "clock.arrow.circlepath", color: .purple,
|
||||
title: "Review Learned",
|
||||
subtitle: "Re-review verbs you've studied — schedule unchanged")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Existing: Vocab Review (due cards)
|
||||
NavigationLink {
|
||||
VocabReviewView()
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "rectangle.stack.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.teal)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Vocab Review")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Review due vocabulary cards")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
let dueCount = VocabReviewView.dueCount(context: cloudModelContext)
|
||||
if dueCount > 0 {
|
||||
Text("\(dueCount)")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(.teal, in: Capsule())
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private func practiceRowLabel(icon: String, color: Color, title: String, subtitle: String) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: icon)
|
||||
.font(.title3)
|
||||
.foregroundStyle(color)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
// MARK: - Practice Session View
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -4,6 +4,8 @@ import SwiftData
|
||||
|
||||
struct SentenceBuilderView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@State private var currentCard: VocabCard?
|
||||
@State private var exampleIndex: Int = 0
|
||||
@@ -316,6 +318,7 @@ struct SentenceBuilderView: View {
|
||||
if isCorrect {
|
||||
sessionCorrect += 1
|
||||
}
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
}
|
||||
|
||||
private func fetchRandomSentenceSelection() -> (card: VocabCard, exampleIndex: Int, spanish: String)? {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
|
||||
struct StoryQuizView: View {
|
||||
let story: Story
|
||||
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@State private var currentIndex = 0
|
||||
@State private var selectedOption: Int?
|
||||
@State private var correctCount = 0
|
||||
@@ -85,6 +89,7 @@ struct StoryQuizView: View {
|
||||
currentIndex += 1
|
||||
selectedOption = nil
|
||||
} else {
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
withAnimation { isFinished = true }
|
||||
}
|
||||
} label: {
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// English-first verb flashcards with two modes, switched from the toolbar:
|
||||
///
|
||||
/// - **Quiz** — the SRS path. `VocabSessionQueue` learning-step queue:
|
||||
/// tap to reveal, rate Again/Hard/Good/Easy, cards requeue, graduation
|
||||
/// feeds the long-term `VerbReviewStore` schedule.
|
||||
/// - **Learn** — no-pressure browsing. Both sides shown at once (English +
|
||||
/// Spanish + example), Next/Previous step through the same session pool
|
||||
/// on a loop. No rating, no SRS side effects.
|
||||
/// Which pool a flashcard session draws from.
|
||||
enum VocabSessionKind {
|
||||
/// Due-first + new verbs, capped — the standard SRS session. Ratings
|
||||
/// update the long-term schedule.
|
||||
case standard
|
||||
/// Verbs already studied at least once, most-recent first, uncapped — a
|
||||
/// consolidation cram. Ratings drive the in-session queue only and do NOT
|
||||
/// reschedule (the long-term SM-2 due dates are left untouched).
|
||||
case reviewLearned
|
||||
}
|
||||
|
||||
struct VocabFlashcardPracticeView: View {
|
||||
enum Mode: String { case quiz, learn }
|
||||
|
||||
var kind: VocabSessionKind = .standard
|
||||
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(VerbExampleCache.self) private var exampleCache
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@AppStorage("vocabFlashcardMode") private var modeRaw: String = Mode.quiz.rawValue
|
||||
|
||||
@State private var session: VocabSessionQueue?
|
||||
@State private var learnIndex: Int = 0
|
||||
@State private var revealed: Bool = false
|
||||
@State private var exampleByVerbId: [Int: VerbExample] = [:]
|
||||
@State private var generatingVerbIds: Set<Int> = []
|
||||
@State private var speech = SpeechService()
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
/// The session's un-graduated verbs, derived live from the quiz queue so
|
||||
/// Learn mode walks exactly what's left to learn — it stays in sync as
|
||||
/// quiz mode graduates cards rather than browsing a stale frozen pool.
|
||||
private var sessionVerbs: [Verb] {
|
||||
session?.queue.map(\.verb) ?? []
|
||||
}
|
||||
|
||||
private var mode: Mode {
|
||||
get { Mode(rawValue: modeRaw) ?? .quiz }
|
||||
nonmutating set { modeRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
private var currentVerb: Verb? {
|
||||
switch mode {
|
||||
case .quiz:
|
||||
return session?.current?.verb
|
||||
case .learn:
|
||||
guard !sessionVerbs.isEmpty else { return nil }
|
||||
return sessionVerbs[learnIndex % sessionVerbs.count]
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
headerBar
|
||||
switch mode {
|
||||
case .quiz:
|
||||
quizContent
|
||||
case .learn:
|
||||
learnContent
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 720)
|
||||
}
|
||||
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Vocab Flashcards")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Picker("Mode", selection: Binding(
|
||||
get: { mode },
|
||||
set: { mode = $0 }
|
||||
)) {
|
||||
Label("Quiz (SRS)", systemImage: "checklist").tag(Mode.quiz)
|
||||
Label("Learn", systemImage: "book").tag(Mode.learn)
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
mode == .learn ? "Learn" : "Quiz",
|
||||
systemImage: mode == .learn ? "book" : "checklist"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear(perform: loadIfNeeded)
|
||||
.onChange(of: modeRaw) { _, _ in
|
||||
revealed = false
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
.animation(.smooth, value: revealed)
|
||||
.animation(.smooth, value: currentVerb?.id)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
@ViewBuilder
|
||||
private var headerBar: some View {
|
||||
VStack(spacing: 6) {
|
||||
switch mode {
|
||||
case .quiz:
|
||||
ProgressView(value: session?.progress ?? 0)
|
||||
.tint(.purple)
|
||||
Text(quizProgressLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
case .learn:
|
||||
if sessionVerbs.isEmpty {
|
||||
Text(emptyPoolMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("\(learnIndex % sessionVerbs.count + 1) of \(sessionVerbs.count)")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if kind == .reviewLearned {
|
||||
Text("Practice pass — your review schedule won't change.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyPoolMessage: String {
|
||||
switch kind {
|
||||
case .standard:
|
||||
return "No verbs match the levels enabled in Settings"
|
||||
case .reviewLearned:
|
||||
return "Nothing studied yet — finish a Vocab Flashcards session first"
|
||||
}
|
||||
}
|
||||
|
||||
private var quizProgressLabel: String {
|
||||
guard let session else { return "Loading…" }
|
||||
if session.isComplete { return "Done" }
|
||||
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
|
||||
}
|
||||
|
||||
// MARK: - Quiz mode
|
||||
|
||||
@ViewBuilder
|
||||
private var quizContent: some View {
|
||||
if let verb = currentVerb {
|
||||
Text(verb.english)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 12)
|
||||
|
||||
if revealed {
|
||||
quizRevealed(verb)
|
||||
} else {
|
||||
tapToReveal
|
||||
}
|
||||
} else {
|
||||
completionView
|
||||
}
|
||||
}
|
||||
|
||||
private var tapToReveal: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "hand.tap")
|
||||
.font(.title)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Tap to reveal")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 200)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 20))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
withAnimation(.smooth) { revealed = true }
|
||||
}
|
||||
}
|
||||
|
||||
private func quizRevealed(_ verb: Verb) -> some View {
|
||||
VStack(spacing: 18) {
|
||||
spanishRow(verb)
|
||||
exampleBlock(for: verb)
|
||||
ratingButtons
|
||||
}
|
||||
}
|
||||
|
||||
private var ratingButtons: some View {
|
||||
VStack(spacing: 10) {
|
||||
Text("How well did you know it?")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 10) {
|
||||
ratingButton("Again", color: .red, rating: .again)
|
||||
ratingButton("Hard", color: .orange, rating: .hard)
|
||||
ratingButton("Good", color: .green, rating: .good)
|
||||
ratingButton("Easy", color: .blue, rating: .easy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ratingButton(_ label: String, color: Color, rating: VocabSessionQueue.Rating) -> some View {
|
||||
Button {
|
||||
answer(rating)
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(color)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var completionView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.green)
|
||||
Text(completionTitle)
|
||||
.font(.title2.bold())
|
||||
Text(completionDetail)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
studyAgain()
|
||||
} label: {
|
||||
Label(
|
||||
kind == .reviewLearned ? "Study Again" : "Next Set",
|
||||
systemImage: kind == .reviewLearned ? "arrow.clockwise" : "arrow.right"
|
||||
)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.purple)
|
||||
|
||||
Button("Done") { dismiss() }
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
.padding(.top, 60)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
private var completionTitle: String {
|
||||
let learned = session?.learnedCount ?? 0
|
||||
switch kind {
|
||||
case .standard:
|
||||
return learned > 0 ? "Session Complete" : "Nothing Due"
|
||||
case .reviewLearned:
|
||||
return learned > 0 ? "Review Complete" : "Nothing to Review"
|
||||
}
|
||||
}
|
||||
|
||||
private var completionDetail: String {
|
||||
let learned = session?.learnedCount ?? 0
|
||||
if learned > 0 {
|
||||
let noun = kind == .reviewLearned ? "reviewed" : "learned"
|
||||
return "\(learned) verb\(learned == 1 ? "" : "s") \(noun)"
|
||||
}
|
||||
switch kind {
|
||||
case .standard:
|
||||
return "No verbs are due right now. Study Again to review anyway."
|
||||
case .reviewLearned:
|
||||
return "Finish a Vocab Flashcards session first, then come back to consolidate."
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Learn mode
|
||||
|
||||
@ViewBuilder
|
||||
private var learnContent: some View {
|
||||
if let verb = currentVerb {
|
||||
Text(verb.english)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 12)
|
||||
|
||||
spanishRow(verb)
|
||||
exampleBlock(for: verb)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
learnStep(-1)
|
||||
} label: {
|
||||
Label("Previous", systemImage: "chevron.left")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(.secondary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
Button {
|
||||
learnStep(1)
|
||||
} label: {
|
||||
Label("Next", systemImage: "chevron.right")
|
||||
.labelStyle(.titleAndIcon)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(.purple)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Nothing to Learn",
|
||||
systemImage: "book",
|
||||
description: Text("Enable some verb levels in Settings.")
|
||||
)
|
||||
.padding(.top, 40)
|
||||
}
|
||||
}
|
||||
|
||||
private func learnStep(_ delta: Int) {
|
||||
guard !sessionVerbs.isEmpty else { return }
|
||||
let count = sessionVerbs.count
|
||||
learnIndex = ((learnIndex + delta) % count + count) % count
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
// MARK: - Shared card pieces
|
||||
|
||||
private func spanishRow(_ verb: Verb) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Text(verb.infinitive)
|
||||
.font(.title.weight(.semibold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button {
|
||||
speech.speak(verb.infinitive)
|
||||
} label: {
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.font(.title3)
|
||||
.padding(10)
|
||||
}
|
||||
.glassEffect(in: .circle)
|
||||
.accessibilityLabel("Say it out loud")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func exampleBlock(for verb: Verb) -> some View {
|
||||
if let example = exampleByVerbId[verb.id] {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(example.spanish).font(.subheadline).italic()
|
||||
Text(example.english).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
} else if generatingVerbIds.contains(verb.id) {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Generating example…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Logic
|
||||
|
||||
private func loadIfNeeded() {
|
||||
guard session == nil else { return }
|
||||
switch kind {
|
||||
case .standard:
|
||||
loadStandardSession()
|
||||
case .reviewLearned:
|
||||
let verbs = VocabVerbPool.reviewLearnedVerbs(localContext: localContext, cloudContext: cloudContext)
|
||||
session = VocabSessionQueue(verbs: verbs)
|
||||
}
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
/// Resume the persisted, cross-device study group if one is active;
|
||||
/// otherwise start a fresh group and persist it.
|
||||
private func loadStandardSession() {
|
||||
let store = VocabStudyGroupStore(context: cloudContext)
|
||||
if let group = store.activeGroup() {
|
||||
let stored = group.entries
|
||||
if !stored.isEmpty {
|
||||
let byId = verbsByID(Set(stored.map(\.verbId)))
|
||||
let entries: [(verb: Verb, state: VocabSessionQueue.CardState)] = stored.compactMap { e in
|
||||
guard let verb = byId[e.verbId] else { return nil }
|
||||
return (verb, VocabSessionQueue.CardState(rawValue: e.state) ?? .new)
|
||||
}
|
||||
// Resume only if EVERY stored verb resolved. A partial resume
|
||||
// would desync learnedCount from a shrunken queue and then
|
||||
// persist that loss — fall through to a fresh rebuild instead.
|
||||
if entries.count == stored.count {
|
||||
session = VocabSessionQueue(entries: entries, learnedCount: group.learnedCount)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// No active group (or it couldn't be fully resolved) — start fresh and
|
||||
// persist immediately so closing the app right away still resumes this set.
|
||||
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
|
||||
session = VocabSessionQueue(verbs: verbs)
|
||||
persistGroup()
|
||||
}
|
||||
|
||||
private func verbsByID(_ ids: Set<Int>) -> [Int: Verb] {
|
||||
let all = (try? localContext.fetch(FetchDescriptor<Verb>())) ?? []
|
||||
var map: [Int: Verb] = [:]
|
||||
for verb in all where ids.contains(verb.id) { map[verb.id] = verb }
|
||||
return map
|
||||
}
|
||||
|
||||
/// Write the standard session's progress to the cloud-synced study group,
|
||||
/// or clear the group when the set is fully learned.
|
||||
private func persistGroup() {
|
||||
guard kind == .standard, let session else { return }
|
||||
let store = VocabStudyGroupStore(context: cloudContext)
|
||||
if session.isComplete {
|
||||
store.clear()
|
||||
} else {
|
||||
let entries = session.snapshot().map {
|
||||
StoredVocabEntry(verbId: $0.verbId, state: $0.state.rawValue)
|
||||
}
|
||||
store.persist(entries: entries, learnedCount: session.learnedCount)
|
||||
}
|
||||
}
|
||||
|
||||
private func studyAgain() {
|
||||
switch kind {
|
||||
case .standard:
|
||||
// Clear the finished group explicitly before building a fresh one,
|
||||
// so a fresh set is never appended onto a stale group record.
|
||||
VocabStudyGroupStore(context: cloudContext).clear()
|
||||
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
|
||||
session = VocabSessionQueue(verbs: verbs)
|
||||
persistGroup()
|
||||
case .reviewLearned:
|
||||
session?.restart()
|
||||
}
|
||||
learnIndex = 0
|
||||
revealed = false
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
private func answer(_ rating: VocabSessionQueue.Rating) {
|
||||
guard let verbId = currentVerb?.id else { return }
|
||||
let graduation = session?.answer(rating) ?? nil
|
||||
// Review Learned is a cram pass — graduation drives the in-session
|
||||
// queue only; the long-term SM-2 schedule is left untouched.
|
||||
if let graduation, kind == .standard {
|
||||
VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation)
|
||||
}
|
||||
persistGroup()
|
||||
withAnimation(.smooth) { revealed = false }
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
private func primeExampleForCurrent() {
|
||||
guard let verb = currentVerb else { return }
|
||||
let verbId = verb.id
|
||||
if exampleByVerbId[verbId] != nil || generatingVerbIds.contains(verbId) { return }
|
||||
|
||||
if let cached = exampleCache.examples(for: verbId)?.first {
|
||||
exampleByVerbId[verbId] = cached
|
||||
return
|
||||
}
|
||||
guard VerbExampleGenerator.isAvailable else { return }
|
||||
generatingVerbIds.insert(verbId)
|
||||
let infinitive = verb.infinitive
|
||||
let english = verb.english
|
||||
let formsByTense = ReferenceStore(context: localContext)
|
||||
.conjugatedForms(verbId: verbId, tenseIds: VocabExampleTenseIds.canonical)
|
||||
Task {
|
||||
do {
|
||||
let examples = try await VerbExampleGenerator.generate(
|
||||
verbInfinitive: infinitive,
|
||||
verbEnglish: english,
|
||||
tenseIds: VocabExampleTenseIds.canonical,
|
||||
formsByTense: formsByTense
|
||||
)
|
||||
exampleCache.setExamples(examples, for: verbId)
|
||||
let pick = examples.first { $0.tenseId == "ind_presente" } ?? examples.first
|
||||
if let pick, currentVerb?.id == verbId {
|
||||
exampleByVerbId[verbId] = pick
|
||||
}
|
||||
} catch {
|
||||
// Silent — the example block just stays hidden.
|
||||
}
|
||||
generatingVerbIds.remove(verbId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// English-first verb multiple choice, driven by `VocabSessionQueue`. 4 options
|
||||
/// (1 correct + 3 random distractors from the session pool). After answering:
|
||||
/// reveal correct/incorrect, the verb infinitive, an example sentence, and SRS
|
||||
/// rating buttons. Again/Hard requeue; a second Good or an Easy graduates.
|
||||
struct VocabMultipleChoicePracticeView: View {
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(VerbExampleCache.self) private var exampleCache
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var session: VocabSessionQueue?
|
||||
@State private var distractorPool: [Verb] = []
|
||||
@State private var options: [Verb] = []
|
||||
@State private var selectedOption: Verb? = nil
|
||||
@State private var exampleByVerbId: [Int: VerbExample] = [:]
|
||||
@State private var generatingVerbIds: Set<Int> = []
|
||||
@State private var speech = SpeechService()
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
private var currentVerb: Verb? { session?.current?.verb }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 22) {
|
||||
progressBar
|
||||
if let verb = currentVerb {
|
||||
questionBody(verb)
|
||||
} else {
|
||||
completionView
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 720)
|
||||
}
|
||||
.navigationTitle("Vocab Multiple Choice")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadIfNeeded)
|
||||
.animation(.smooth, value: selectedOption?.id)
|
||||
.animation(.smooth, value: currentVerb?.id)
|
||||
}
|
||||
|
||||
// MARK: - Progress
|
||||
|
||||
private var progressBar: some View {
|
||||
VStack(spacing: 6) {
|
||||
ProgressView(value: session?.progress ?? 0)
|
||||
.tint(.purple)
|
||||
Text(progressLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var progressLabel: String {
|
||||
guard let session else { return "Loading…" }
|
||||
if session.isComplete { return "Done" }
|
||||
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
|
||||
}
|
||||
|
||||
// MARK: - Question
|
||||
|
||||
@ViewBuilder
|
||||
private func questionBody(_ verb: Verb) -> some View {
|
||||
Text(verb.english)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 12)
|
||||
|
||||
if selectedOption == nil {
|
||||
optionGrid
|
||||
} else {
|
||||
revealedContent(verb)
|
||||
}
|
||||
}
|
||||
|
||||
private var optionGrid: some View {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(options, id: \.id) { option in
|
||||
Button {
|
||||
selectedOption = option
|
||||
} label: {
|
||||
Text(option.infinitive)
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.tint(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func revealedContent(_ verb: Verb) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
answerFeedback(verb)
|
||||
exampleBlock(for: verb)
|
||||
ratingButtons
|
||||
}
|
||||
}
|
||||
|
||||
private func answerFeedback(_ verb: Verb) -> some View {
|
||||
let correct = (selectedOption?.id == verb.id)
|
||||
return VStack(spacing: 6) {
|
||||
Image(systemName: correct ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(correct ? .green : .red)
|
||||
Text(correct ? "Correct!" : "Not quite")
|
||||
.font(.headline)
|
||||
.foregroundStyle(correct ? .green : .red)
|
||||
HStack(spacing: 10) {
|
||||
Text(verb.infinitive)
|
||||
.font(.title2.weight(.semibold))
|
||||
|
||||
Button {
|
||||
speech.speak(verb.infinitive)
|
||||
} label: {
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.font(.body)
|
||||
.padding(8)
|
||||
}
|
||||
.glassEffect(in: .circle)
|
||||
.accessibilityLabel("Say it out loud")
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func exampleBlock(for verb: Verb) -> some View {
|
||||
if let example = exampleByVerbId[verb.id] {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(example.spanish).font(.subheadline).italic()
|
||||
Text(example.english).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
} else if generatingVerbIds.contains(verb.id) {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Generating example…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
private var ratingButtons: some View {
|
||||
VStack(spacing: 10) {
|
||||
Text("How well did you know it?")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 10) {
|
||||
ratingButton("Again", color: .red, rating: .again)
|
||||
ratingButton("Hard", color: .orange, rating: .hard)
|
||||
ratingButton("Good", color: .green, rating: .good)
|
||||
ratingButton("Easy", color: .blue, rating: .easy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ratingButton(_ label: String, color: Color, rating: VocabSessionQueue.Rating) -> some View {
|
||||
Button {
|
||||
answer(rating)
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(color)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// MARK: - Completion
|
||||
|
||||
private var completionView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.green)
|
||||
Text((session?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due")
|
||||
.font(.title2.bold())
|
||||
Text(completionDetail)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
studyAgain()
|
||||
} label: {
|
||||
Label("Study Again", systemImage: "arrow.clockwise")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.purple)
|
||||
|
||||
Button("Done") { dismiss() }
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
.padding(.top, 60)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
private var completionDetail: String {
|
||||
let learned = session?.learnedCount ?? 0
|
||||
if learned > 0 {
|
||||
return "\(learned) verb\(learned == 1 ? "" : "s") learned"
|
||||
}
|
||||
return "No verbs are due right now. Study Again to review anyway."
|
||||
}
|
||||
|
||||
// MARK: - Logic
|
||||
|
||||
private func loadIfNeeded() {
|
||||
guard session == nil else { return }
|
||||
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
|
||||
distractorPool = verbs
|
||||
session = VocabSessionQueue(verbs: verbs)
|
||||
prepareOptions()
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
private func studyAgain() {
|
||||
session?.restart()
|
||||
selectedOption = nil
|
||||
prepareOptions()
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
private func prepareOptions() {
|
||||
guard let verb = currentVerb else { options = []; return }
|
||||
let candidates = distractorPool.filter { $0.id != verb.id }
|
||||
let distractors = Array(candidates.shuffled().prefix(3))
|
||||
options = ([verb] + distractors).shuffled()
|
||||
}
|
||||
|
||||
private func answer(_ rating: VocabSessionQueue.Rating) {
|
||||
guard let verbId = currentVerb?.id else { return }
|
||||
let graduation = session?.answer(rating) ?? nil
|
||||
if let graduation {
|
||||
VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation)
|
||||
}
|
||||
selectedOption = nil
|
||||
prepareOptions()
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
private func primeExampleForCurrent() {
|
||||
guard let verb = currentVerb else { return }
|
||||
let verbId = verb.id
|
||||
if exampleByVerbId[verbId] != nil || generatingVerbIds.contains(verbId) { return }
|
||||
if let cached = exampleCache.examples(for: verbId)?.first {
|
||||
exampleByVerbId[verbId] = cached
|
||||
return
|
||||
}
|
||||
guard VerbExampleGenerator.isAvailable else { return }
|
||||
generatingVerbIds.insert(verbId)
|
||||
let infinitive = verb.infinitive
|
||||
let english = verb.english
|
||||
let formsByTense = ReferenceStore(context: localContext)
|
||||
.conjugatedForms(verbId: verbId, tenseIds: VocabExampleTenseIds.canonical)
|
||||
Task {
|
||||
do {
|
||||
let examples = try await VerbExampleGenerator.generate(
|
||||
verbInfinitive: infinitive,
|
||||
verbEnglish: english,
|
||||
tenseIds: VocabExampleTenseIds.canonical,
|
||||
formsByTense: formsByTense
|
||||
)
|
||||
exampleCache.setExamples(examples, for: verbId)
|
||||
let pick = examples.first { $0.tenseId == "ind_presente" } ?? examples.first
|
||||
if let pick, currentVerb?.id == verbId {
|
||||
exampleByVerbId[verbId] = pick
|
||||
}
|
||||
} catch {}
|
||||
generatingVerbIds.remove(verbId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,7 @@ struct VocabReviewView: View {
|
||||
private func rate(quality: ReviewQuality) {
|
||||
guard let card = dueCards[safe: currentIndex] else { return }
|
||||
|
||||
ReviewStore.recordActivity(context: cloudContext)
|
||||
let store = CourseReviewStore(context: cloudContext)
|
||||
let result = SRSEngine.review(
|
||||
quality: quality,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
|
||||
/// Lists downloaded YouTube videos with per-item deletion and total-size
|
||||
/// summary (Issue #21, phase 4). Files live in the local (non-synced)
|
||||
/// SwiftData container and the app's documents directory.
|
||||
struct DownloadedVideosView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query(sort: \DownloadedVideo.downloadedAt, order: .reverse)
|
||||
private var downloads: [DownloadedVideo]
|
||||
|
||||
@State private var downloadService = VideoDownloadService.shared
|
||||
@State private var confirmDeleteAll = false
|
||||
|
||||
private var totalBytes: Int {
|
||||
downloads.reduce(0) { $0 + $1.byteCount }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if downloads.isEmpty {
|
||||
Section {
|
||||
ContentUnavailableView(
|
||||
"No downloads",
|
||||
systemImage: "arrow.down.to.line",
|
||||
description: Text("Tap Download on any guide or grammar video to save it for offline viewing.")
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
LabeledContent("Total size", value: sizeString(totalBytes))
|
||||
if totalBytes > 500_000_000 {
|
||||
Label("Downloads exceed 500 MB", systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Videos") {
|
||||
ForEach(downloads) { download in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(download.title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.lineLimit(2)
|
||||
HStack {
|
||||
Text(sizeString(download.byteCount))
|
||||
Text("·")
|
||||
Text(download.downloadedAt.formatted(date: .abbreviated, time: .omitted))
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.swipeActions {
|
||||
Button(role: .destructive) {
|
||||
downloadService.delete(videoId: download.videoId, modelContext: modelContext)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
confirmDeleteAll = true
|
||||
} label: {
|
||||
Label("Delete all downloads", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Downloaded Videos")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.confirmationDialog(
|
||||
"Delete all \(downloads.count) downloaded videos?",
|
||||
isPresented: $confirmDeleteAll,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete All", role: .destructive) {
|
||||
for download in downloads {
|
||||
downloadService.delete(videoId: download.videoId, modelContext: modelContext)
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
}
|
||||
|
||||
private func sizeString(_ bytes: Int) -> String {
|
||||
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
DownloadedVideosView()
|
||||
}
|
||||
.modelContainer(for: DownloadedVideo.self, inMemory: true)
|
||||
}
|
||||
@@ -3,15 +3,17 @@ import SwiftUI
|
||||
struct FeatureReferenceView: View {
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Verb Conjugation Practice") {
|
||||
// MARK: Conjugation
|
||||
|
||||
Section("Practice — Conjugation") {
|
||||
featureRow(
|
||||
icon: "rectangle.stack", color: .blue,
|
||||
title: "Flashcard / Typing / MC / Handwriting / Sentence Builder",
|
||||
details: [
|
||||
"Pulls from verb conjugation database (1,750 verbs)",
|
||||
"Pulls from the verb conjugation database (1,750 verbs)",
|
||||
"Filtered by your Level setting",
|
||||
"Filtered by your Enabled Tenses",
|
||||
"Respects Include Vosotros setting",
|
||||
"Respects the Include Vosotros setting",
|
||||
"Due cards (SRS) shown first, then random",
|
||||
]
|
||||
)
|
||||
@@ -21,13 +23,11 @@ struct FeatureReferenceView: View {
|
||||
title: "Full Table",
|
||||
details: [
|
||||
"Shows all 6 person forms for one verb + tense",
|
||||
"Random verb from your Level",
|
||||
"Drawn from any regular verb — Level filter is ignored here on purpose, since regular conjugation patterns transfer across vocabulary",
|
||||
"Random tense from your Enabled Tenses",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
Section("Quick Actions") {
|
||||
featureRow(
|
||||
icon: "star.fill", color: .orange,
|
||||
title: "Common Tenses",
|
||||
@@ -57,20 +57,86 @@ struct FeatureReferenceView: View {
|
||||
"Filtered by your Level and Enabled Tenses",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Vocabulary
|
||||
|
||||
Section("Practice — Vocabulary") {
|
||||
featureRow(
|
||||
icon: "rectangle.on.rectangle.angled", color: .purple,
|
||||
title: "Vocab Flashcards",
|
||||
details: [
|
||||
"English meaning → recall the Spanish verb",
|
||||
"Pool = verbs at your enabled Levels (the same Level set the Verbs tab filters by)",
|
||||
"Session size set by Settings → Cards per session",
|
||||
"Overdue verbs pulled first, then new verbs by frequency",
|
||||
"Quiz mode: tap to reveal, rate Again/Hard/Good/Easy. Again/Hard requeue the card a few cards later; a second Good or an Easy graduates it. Graduation updates the long-term SRS schedule.",
|
||||
"Learn mode (toolbar toggle): both sides shown at once, Next/Previous to browse, loops — no rating, no pressure",
|
||||
"Example sentence generated on-device; speaker button reads the verb aloud",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "checklist", color: .purple,
|
||||
title: "Vocab Multiple Choice",
|
||||
details: [
|
||||
"English meaning → pick the Spanish verb from 4 options",
|
||||
"Distractors prefer the same part of speech",
|
||||
"Same level-filtered pool and SRS session queue as Vocab Flashcards",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "rectangle.stack.fill", color: .teal,
|
||||
title: "Vocab Review",
|
||||
details: [
|
||||
"Reviews vocabulary cards that are due (SRS scheduled)",
|
||||
"Reviews course/textbook vocabulary cards that are due (SRS scheduled)",
|
||||
"Cards become due after you study them in Course quizzes",
|
||||
"Rate Again/Hard/Good/Easy to schedule next review",
|
||||
"Uses all course vocabulary, not filtered by level",
|
||||
"Rate Again/Hard/Good/Easy to schedule the next review",
|
||||
"Distinct from Vocab Flashcards — this is course vocab, not the verb table",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
Section("Practice Activities") {
|
||||
// MARK: Reading
|
||||
|
||||
Section("Practice — Reading") {
|
||||
featureRow(
|
||||
icon: "book.fill", color: .teal,
|
||||
title: "Stories",
|
||||
details: [
|
||||
"AI-generated one-paragraph Spanish stories",
|
||||
"Matched to your Level and Enabled Tenses",
|
||||
"Every word is tappable for a definition",
|
||||
"English translation hidden by default (toggle to reveal)",
|
||||
"3-question comprehension quiz at the end",
|
||||
"Requires an Apple Intelligence-capable device",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "books.vertical.fill", color: .indigo,
|
||||
title: "Books",
|
||||
details: [
|
||||
"Full-length bilingual books bundled with the app",
|
||||
"Chapter list; read a chapter paragraph by paragraph",
|
||||
"Tap any word for a definition (offline dictionary, on-device AI fallback)",
|
||||
"Toggle between Spanish and pre-translated English",
|
||||
"Read-aloud: a voice reads the chapter with the current word highlighted; tap a word to pause and look it up",
|
||||
"Voice and speed picker in the read-aloud controls",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "music.note.list", color: .pink,
|
||||
title: "Lyrics",
|
||||
details: [
|
||||
"Search and save Spanish song lyrics",
|
||||
"Side-by-side Spanish and English",
|
||||
"Long-press a word for a definition",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "bubble.left.and.bubble.right.fill", color: .green,
|
||||
title: "Conversation",
|
||||
@@ -79,8 +145,7 @@ struct FeatureReferenceView: View {
|
||||
"10 scenario types (restaurant, directions, etc.)",
|
||||
"AI adapts vocabulary to your Level setting",
|
||||
"Corrections provided inline when you make mistakes",
|
||||
"Conversations saved to iCloud for revisiting",
|
||||
"Requires Apple Intelligence-capable device",
|
||||
"Requires an Apple Intelligence-capable device",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -91,8 +156,6 @@ struct FeatureReferenceView: View {
|
||||
"Listen & Type: hear a sentence, type what you heard",
|
||||
"Pronunciation: read a sentence aloud, get scored on accuracy",
|
||||
"Sentences pulled from course vocabulary examples",
|
||||
"Uses all course vocab (not filtered by level)",
|
||||
"Pronunciation requires microphone permission",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -101,50 +164,23 @@ struct FeatureReferenceView: View {
|
||||
title: "Cloze Practice",
|
||||
details: [
|
||||
"Fill in the missing word in a Spanish sentence",
|
||||
"Sentences from course vocabulary examples",
|
||||
"4 multiple-choice options (1 correct + 3 distractors)",
|
||||
"Distractors are other vocabulary words from same pool",
|
||||
"Uses all course vocab (not filtered by level)",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "music.note.list", color: .pink,
|
||||
title: "Lyrics",
|
||||
details: [
|
||||
"Search and save Spanish song lyrics",
|
||||
"Side-by-side Spanish and English translations",
|
||||
"User-curated library, not filtered by level",
|
||||
"Saved to iCloud for sync across devices",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "book.fill", color: .teal,
|
||||
title: "Stories",
|
||||
details: [
|
||||
"AI-generated one-paragraph Spanish stories",
|
||||
"Matched to your Level and Enabled Tenses",
|
||||
"Every word is tappable for definition",
|
||||
"Known words use offline dictionary (175K+ verb forms)",
|
||||
"Unknown words looked up via on-device AI",
|
||||
"English translation hidden by default (toggle to reveal)",
|
||||
"3-question comprehension quiz at the end",
|
||||
"Saved to iCloud for revisiting",
|
||||
"Requires Apple Intelligence-capable device",
|
||||
"Sentences from course vocabulary examples",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Guide
|
||||
|
||||
Section("Guide") {
|
||||
featureRow(
|
||||
icon: "book", color: .brown,
|
||||
title: "Tense Guides",
|
||||
details: [
|
||||
"Detailed explanation of each of the 20 verb tenses",
|
||||
"Conjugation ending tables for -ar, -er, -ir verbs",
|
||||
"Usage patterns with example sentences",
|
||||
"Essential tenses marked with orange badge",
|
||||
"In-depth guide to each of the 20 verb tenses",
|
||||
"Conjugation ending tables, common irregulars, mnemonics",
|
||||
"Usage patterns, pitfalls, and contrast with neighbouring tenses",
|
||||
"Essential tenses marked with an orange badge",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -152,14 +188,24 @@ struct FeatureReferenceView: View {
|
||||
icon: "doc.text", color: .brown,
|
||||
title: "Grammar Notes",
|
||||
details: [
|
||||
"23 grammar topics (ser vs estar, por vs para, etc.)",
|
||||
"Interactive exercises available for 5 topics",
|
||||
"Tap 'Practice This' on notes that have exercises",
|
||||
"Content grouped by category with card-based layout",
|
||||
"36 grammar topics (ser vs estar, por vs para, WEIRDO, etc.)",
|
||||
"Each with a mnemonic, contrast examples, and common pitfalls",
|
||||
"Interactive exercises available on selected topics",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "arrow.triangle.branch", color: .indigo,
|
||||
title: "Cross-links",
|
||||
details: [
|
||||
"Tense guides show \"Related grammar\" chips that jump to the matching grammar note",
|
||||
"Grammar notes show \"Used in tenses\" chips that jump back",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Course
|
||||
|
||||
Section("Course") {
|
||||
featureRow(
|
||||
icon: "list.clipboard", color: .orange,
|
||||
@@ -167,11 +213,21 @@ struct FeatureReferenceView: View {
|
||||
details: [
|
||||
"Vocabulary from specific course weeks",
|
||||
"Multiple quiz types: MC, typing, handwriting, cloze",
|
||||
"Focus Area mode for missed words",
|
||||
"Not filtered by Level (uses course structure)",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "star.circle.fill", color: .yellow,
|
||||
title: "Extra Study",
|
||||
details: [
|
||||
"Star a card during a course flashcard session to mark it for extra study",
|
||||
"Each week shows an \"Extra Study\" row when it has starred cards",
|
||||
"Launches a session of just the starred cards for that week",
|
||||
"Marks are iCloud-synced across devices",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "checkmark.seal", color: .orange,
|
||||
title: "Checkpoint Exams",
|
||||
@@ -183,23 +239,27 @@ struct FeatureReferenceView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Dashboard
|
||||
|
||||
Section("Dashboard") {
|
||||
featureRow(
|
||||
icon: "clock.fill", color: .mint,
|
||||
title: "Study Time",
|
||||
details: [
|
||||
"Tracks time the app is in the foreground",
|
||||
"Starts when app becomes active, stops on background",
|
||||
"Shows today's time and all-time total",
|
||||
"Shows today's time and an all-time total",
|
||||
"7-day bar chart of daily study time",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Settings
|
||||
|
||||
Section("Settings That Affect Practice") {
|
||||
settingRow(name: "Level", affects: "Verb practice, Full Table, Quick Actions, Stories, Conversation")
|
||||
settingRow(name: "Enabled Tenses", affects: "Verb practice, Full Table, Irregularity Drills, Stories")
|
||||
settingRow(name: "Include Vosotros", affects: "Verb practice, Full Table, Quick Actions")
|
||||
settingRow(name: "Level", affects: "Conjugation practice, Vocab Flashcards & Multiple Choice, Stories, Conversation. Shared with the Verbs tab filter. Full Table ignores level.")
|
||||
settingRow(name: "Enabled Tenses", affects: "Conjugation practice, Full Table, Irregularity Drills, Stories")
|
||||
settingRow(name: "Include Vosotros", affects: "Conjugation practice, Full Table, Common Tenses")
|
||||
settingRow(name: "Cards per session", affects: "How many verbs a Vocab Flashcards / Multiple Choice session draws")
|
||||
settingRow(name: "Daily Goal", affects: "Dashboard progress tracking only")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,15 @@ struct SettingsView: View {
|
||||
@State private var dailyGoal: Double = 50
|
||||
@State private var showVosotros: Bool = true
|
||||
@State private var autoFillStem: Bool = false
|
||||
@State private var selectedLevel: VerbLevel = .basic
|
||||
|
||||
/// Cards per vocab-practice session. 999 = "All" (no cap).
|
||||
@AppStorage("vocabSessionCardLimit") private var vocabSessionCardLimit: Int = 20
|
||||
private let vocabSessionSizes: [Int] = [10, 15, 20, 25, 30, 50, 999]
|
||||
|
||||
private let levels = VerbLevel.allCases
|
||||
private let irregularCategories: [IrregularSpan.SpanCategory] = [
|
||||
.spelling, .stemChange, .uniqueIrregular
|
||||
]
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
@@ -40,19 +46,38 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("Level") {
|
||||
Picker("Current Level", selection: $selectedLevel) {
|
||||
ForEach(levels, id: \.self) { level in
|
||||
Text(level.displayName).tag(level)
|
||||
Section {
|
||||
Picker("Cards per session", selection: $vocabSessionCardLimit) {
|
||||
ForEach(vocabSessionSizes, id: \.self) { size in
|
||||
Text(size == 999 ? "All" : "\(size)").tag(size)
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedLevel) { _, newValue in
|
||||
progress?.selectedVerbLevel = newValue
|
||||
saveProgress()
|
||||
}
|
||||
} header: {
|
||||
Text("Vocab Flashcards")
|
||||
} footer: {
|
||||
Text("How many verbs a Vocab Flashcards session draws. Overdue verbs are pulled first, then new ones.")
|
||||
}
|
||||
|
||||
Section("Tenses") {
|
||||
Section {
|
||||
ForEach(levels, id: \.self) { level in
|
||||
Toggle(level.displayName, isOn: Binding(
|
||||
get: {
|
||||
progress?.selectedVerbLevels.contains(level) ?? false
|
||||
},
|
||||
set: { enabled in
|
||||
guard let progress else { return }
|
||||
progress.setLevelEnabled(level, enabled: enabled)
|
||||
saveProgress()
|
||||
}
|
||||
))
|
||||
}
|
||||
} header: {
|
||||
Text("Levels")
|
||||
} footer: {
|
||||
Text("Practice pulls only from verbs whose level is enabled. Turn on multiple to mix.")
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(TenseInfo.all) { tense in
|
||||
Toggle(tense.english, isOn: Binding(
|
||||
get: {
|
||||
@@ -65,6 +90,41 @@ struct SettingsView: View {
|
||||
}
|
||||
))
|
||||
}
|
||||
} header: {
|
||||
Text("Tenses")
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(irregularCategories, id: \.self) { category in
|
||||
Toggle(category.rawValue, isOn: Binding(
|
||||
get: {
|
||||
progress?.enabledIrregularCategories.contains(category) ?? false
|
||||
},
|
||||
set: { enabled in
|
||||
guard let progress else { return }
|
||||
progress.setIrregularCategoryEnabled(category, enabled: enabled)
|
||||
saveProgress()
|
||||
}
|
||||
))
|
||||
}
|
||||
} header: {
|
||||
Text("Irregular Types")
|
||||
} footer: {
|
||||
Text("Leave all off to include regular and irregular verbs. Enable any to restrict practice to those irregularity types.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Reflexive verbs only", isOn: Binding(
|
||||
get: { progress?.showReflexiveVerbsOnly ?? false },
|
||||
set: { enabled in
|
||||
progress?.showReflexiveVerbsOnly = enabled
|
||||
saveProgress()
|
||||
}
|
||||
))
|
||||
} header: {
|
||||
Text("Reflexive")
|
||||
} footer: {
|
||||
Text("When on, practice pulls only from the curated list of common reflexive verbs.")
|
||||
}
|
||||
|
||||
Section("Stats") {
|
||||
@@ -79,6 +139,9 @@ struct SettingsView: View {
|
||||
NavigationLink("How Features Work") {
|
||||
FeatureReferenceView()
|
||||
}
|
||||
NavigationLink("Downloaded Videos") {
|
||||
DownloadedVideosView()
|
||||
}
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
@@ -96,7 +159,6 @@ struct SettingsView: View {
|
||||
dailyGoal = Double(resolved.dailyGoal)
|
||||
showVosotros = resolved.showVosotros
|
||||
autoFillStem = resolved.autoFillStem
|
||||
selectedLevel = resolved.selectedVerbLevel
|
||||
}
|
||||
|
||||
private func saveProgress() {
|
||||
|
||||
@@ -4,14 +4,40 @@ import SwiftData
|
||||
|
||||
struct VerbDetailView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(VerbExampleCache.self) private var exampleCache
|
||||
@Environment(ReflexiveVerbStore.self) private var reflexiveStore
|
||||
@State private var speechService = SpeechService()
|
||||
let verb: Verb
|
||||
@State private var selectedTense: TenseInfo = TenseInfo.all[0]
|
||||
|
||||
@State private var examples: [VerbExample] = []
|
||||
@State private var examplesState: ExamplesState = .idle
|
||||
|
||||
private enum ExamplesState: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case loaded
|
||||
case unavailable
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
private static let exampleTenseIds: [String] = [
|
||||
TenseID.ind_presente.rawValue,
|
||||
TenseID.ind_preterito.rawValue,
|
||||
TenseID.ind_imperfecto.rawValue,
|
||||
TenseID.ind_futuro.rawValue,
|
||||
TenseID.subj_presente.rawValue,
|
||||
TenseID.imp_afirmativo.rawValue,
|
||||
]
|
||||
|
||||
private var formsForTense: [VerbForm] {
|
||||
ReferenceStore(context: modelContext).fetchForms(verbId: verb.id, tenseId: selectedTense.id)
|
||||
}
|
||||
|
||||
private var reflexiveEntries: [ReflexiveVerb] {
|
||||
reflexiveStore.entries(for: verb.infinitive)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
@@ -25,6 +51,10 @@ struct VerbDetailView: View {
|
||||
Text("Info")
|
||||
}
|
||||
|
||||
if !reflexiveEntries.isEmpty {
|
||||
reflexiveSection
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Tense", selection: $selectedTense) {
|
||||
ForEach(TenseInfo.all) { tense in
|
||||
@@ -66,6 +96,8 @@ struct VerbDetailView: View {
|
||||
} header: {
|
||||
Text("Conjugation")
|
||||
}
|
||||
|
||||
examplesSection
|
||||
}
|
||||
.navigationTitle(verb.infinitive)
|
||||
.toolbar {
|
||||
@@ -78,6 +110,129 @@ struct VerbDetailView: View {
|
||||
.tint(.secondary)
|
||||
}
|
||||
}
|
||||
.task(id: verb.id) {
|
||||
await loadExamples()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reflexive
|
||||
|
||||
private var reflexiveSection: some View {
|
||||
Section {
|
||||
ForEach(Array(reflexiveEntries.enumerated()), id: \.offset) { _, entry in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(entry.infinitive)
|
||||
.font(.body.weight(.semibold))
|
||||
.italic()
|
||||
if let hint = entry.usageHint, !hint.isEmpty {
|
||||
Text(hint)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
Text(entry.english)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
} header: {
|
||||
Text("Reflexive")
|
||||
} footer: {
|
||||
if reflexiveEntries.contains(where: { $0.usageHint != nil }) {
|
||||
Text("Highlighted words are prepositions or phrases this verb commonly pairs with.")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Examples
|
||||
|
||||
@ViewBuilder
|
||||
private var examplesSection: some View {
|
||||
Section {
|
||||
switch examplesState {
|
||||
case .idle, .loading:
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Generating examples…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
case .unavailable:
|
||||
Label("Examples require Apple Intelligence on this device.", systemImage: "sparkles")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
case .failed(let message):
|
||||
Label(message, systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
case .loaded:
|
||||
if examples.isEmpty {
|
||||
Label("No examples available.", systemImage: "text.quote")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(Array(examples.enumerated()), id: \.offset) { _, example in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let info = TenseInfo.find(example.tenseId) {
|
||||
Text(info.english)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
Text(example.spanish)
|
||||
.font(.body)
|
||||
.italic()
|
||||
Text(example.english)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Examples")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadExamples() async {
|
||||
// Reset state when navigating between verbs via NavigationSplitView.
|
||||
examples = []
|
||||
examplesState = .idle
|
||||
|
||||
if let cached = exampleCache.examples(for: verb.id), !cached.isEmpty {
|
||||
examples = cached
|
||||
examplesState = .loaded
|
||||
return
|
||||
}
|
||||
|
||||
guard VerbExampleGenerator.isAvailable else {
|
||||
examplesState = .unavailable
|
||||
return
|
||||
}
|
||||
|
||||
examplesState = .loading
|
||||
do {
|
||||
let formsByTense = ReferenceStore(context: modelContext)
|
||||
.conjugatedForms(verbId: verb.id, tenseIds: Self.exampleTenseIds)
|
||||
let generated = try await VerbExampleGenerator.generate(
|
||||
verbInfinitive: verb.infinitive,
|
||||
verbEnglish: verb.english,
|
||||
tenseIds: Self.exampleTenseIds,
|
||||
formsByTense: formsByTense
|
||||
)
|
||||
guard !generated.isEmpty else {
|
||||
examplesState = .failed("Could not generate examples.")
|
||||
return
|
||||
}
|
||||
exampleCache.setExamples(generated, for: verb.id)
|
||||
examples = generated
|
||||
examplesState = .loaded
|
||||
} catch {
|
||||
examplesState = .failed("Could not generate examples.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,4 +241,6 @@ struct VerbDetailView: View {
|
||||
VerbDetailView(verb: Verb(id: 1, infinitive: "hablar", english: "to speak", rank: 1, ending: "ar", reflexive: 0, level: "basic"))
|
||||
}
|
||||
.modelContainer(for: [Verb.self, VerbForm.self], inMemory: true)
|
||||
.environment(VerbExampleCache())
|
||||
.environment(ReflexiveVerbStore())
|
||||
}
|
||||
|
||||
@@ -2,17 +2,67 @@ import SwiftUI
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
|
||||
enum IrregularityCategory: String, CaseIterable, Identifiable {
|
||||
case anyIrregular = "Any Irregular"
|
||||
case spelling = "Spelling Change"
|
||||
case stemChange = "Stem Change"
|
||||
case uniqueIrregular = "Unique Irregular"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .anyIrregular: "asterisk"
|
||||
case .spelling: "character.cursor.ibeam"
|
||||
case .stemChange: "arrow.triangle.2.circlepath"
|
||||
case .uniqueIrregular: "star"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VerbListView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(ReflexiveVerbStore.self) private var reflexiveStore
|
||||
@State private var verbs: [Verb] = []
|
||||
@State private var irregularityByVerbId: [Int: Set<IrregularityCategory>] = [:]
|
||||
@State private var searchText = ""
|
||||
@State private var selectedLevel: String?
|
||||
@State private var progress: UserProgress?
|
||||
@State private var selectedIrregularity: IrregularityCategory?
|
||||
@State private var reflexiveOnly: Bool = false
|
||||
@State private var selectedVerb: Verb?
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
/// Levels currently enabled in `UserProgress` — the same set that drives
|
||||
/// what Practice picks from. The Verbs tab reads and writes the same
|
||||
/// state so changes here propagate to Practice and vice versa.
|
||||
private var selectedLevels: Set<VerbLevel> {
|
||||
progress?.selectedVerbLevels ?? []
|
||||
}
|
||||
|
||||
/// True when the user has every available level enabled (or none, which
|
||||
/// we treat as "no filter applied" on the Verbs list specifically).
|
||||
private var allLevelsActive: Bool {
|
||||
selectedLevels.isEmpty || selectedLevels.count == VerbLevel.allCases.count
|
||||
}
|
||||
|
||||
private var filteredVerbs: [Verb] {
|
||||
var result = verbs
|
||||
if let level = selectedLevel {
|
||||
result = result.filter { VerbLevelGroup.matches($0.level, selectedLevel: level) }
|
||||
if !allLevelsActive {
|
||||
let activeRaws = Set(selectedLevels.map(\.rawValue))
|
||||
result = result.filter { verb in
|
||||
activeRaws.contains { VerbLevelGroup.matches(verb.level, selectedLevel: $0) }
|
||||
}
|
||||
}
|
||||
if let category = selectedIrregularity {
|
||||
result = result.filter { verb in
|
||||
guard let cats = irregularityByVerbId[verb.id] else { return false }
|
||||
return category == .anyIrregular ? !cats.isEmpty : cats.contains(category)
|
||||
}
|
||||
}
|
||||
if reflexiveOnly {
|
||||
result = result.filter { reflexiveStore.isReflexive(baseInfinitive: $0.infinitive) }
|
||||
}
|
||||
if !searchText.isEmpty {
|
||||
let query = searchText.lowercased()
|
||||
@@ -24,31 +74,75 @@ struct VerbListView: View {
|
||||
return result
|
||||
}
|
||||
|
||||
private let levels = ["basic", "elementary", "intermediate", "advanced", "expert"]
|
||||
private let levels: [VerbLevel] = VerbLevel.allCases
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(filteredVerbs, selection: $selectedVerb) { verb in
|
||||
NavigationLink(value: verb) {
|
||||
VerbRowView(verb: verb)
|
||||
VerbRowView(verb: verb, irregularities: irregularityByVerbId[verb.id] ?? [])
|
||||
}
|
||||
}
|
||||
.navigationTitle("Verbs")
|
||||
.searchable(text: $searchText, prompt: "Search verbs...")
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
if hasActiveFilter {
|
||||
activeFilterBar
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button("All Levels") { selectedLevel = nil }
|
||||
ForEach(levels, id: \.self) { level in
|
||||
Button(level.capitalized) { selectedLevel = level }
|
||||
Section("Level") {
|
||||
Button {
|
||||
setAllLevels(enabled: true)
|
||||
} label: {
|
||||
Label("All Levels", systemImage: allLevelsActive ? "checkmark" : "circle")
|
||||
}
|
||||
ForEach(levels, id: \.self) { level in
|
||||
Button {
|
||||
toggleLevel(level)
|
||||
} label: {
|
||||
Label(level.displayName, systemImage: selectedLevels.contains(level) ? "checkmark" : "circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Irregularity") {
|
||||
Button {
|
||||
selectedIrregularity = nil
|
||||
} label: {
|
||||
Label("All Verbs", systemImage: selectedIrregularity == nil ? "checkmark" : "circle")
|
||||
}
|
||||
ForEach(IrregularityCategory.allCases) { category in
|
||||
Button {
|
||||
selectedIrregularity = category
|
||||
} label: {
|
||||
Label(category.rawValue, systemImage: selectedIrregularity == category ? "checkmark" : category.systemImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Reflexive") {
|
||||
Button {
|
||||
reflexiveOnly.toggle()
|
||||
} label: {
|
||||
Label("Reflexive verbs only", systemImage: reflexiveOnly ? "checkmark" : "arrow.triangle.2.circlepath")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Filter", systemImage: "line.3.horizontal.decrease.circle")
|
||||
Label("Filter", systemImage: hasActiveFilter ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { loadVerbs() }
|
||||
.onAppear { loadVerbs() }
|
||||
.task {
|
||||
loadVerbs()
|
||||
loadProgress()
|
||||
}
|
||||
.onAppear {
|
||||
loadVerbs()
|
||||
loadProgress()
|
||||
}
|
||||
} detail: {
|
||||
if let verb = selectedVerb {
|
||||
VerbDetailView(verb: verb)
|
||||
@@ -58,6 +152,56 @@ struct VerbListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var hasActiveFilter: Bool {
|
||||
!allLevelsActive || selectedIrregularity != nil || reflexiveOnly
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var activeFilterBar: some View {
|
||||
HStack(spacing: 8) {
|
||||
if !allLevelsActive {
|
||||
filterChip(text: levelChipLabel, systemImage: "graduationcap") {
|
||||
setAllLevels(enabled: true)
|
||||
}
|
||||
}
|
||||
if let cat = selectedIrregularity {
|
||||
filterChip(text: cat.rawValue, systemImage: cat.systemImage) {
|
||||
selectedIrregularity = nil
|
||||
}
|
||||
}
|
||||
if reflexiveOnly {
|
||||
filterChip(text: "Reflexive", systemImage: "arrow.triangle.2.circlepath") {
|
||||
reflexiveOnly = false
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text("\(filteredVerbs.count)")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private func filterChip(text: String, systemImage: String, onClear: @escaping () -> Void) -> some View {
|
||||
Button(action: onClear) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.caption2)
|
||||
Text(text)
|
||||
.font(.caption.weight(.medium))
|
||||
Image(systemName: "xmark")
|
||||
.font(.caption2)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(.blue.opacity(0.15), in: Capsule())
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func loadVerbs() {
|
||||
// Hit the shared local container directly, bypassing @Environment.
|
||||
guard let container = SharedStore.localContainer else {
|
||||
@@ -69,12 +213,64 @@ struct VerbListView: View {
|
||||
}
|
||||
let context = ModelContext(container)
|
||||
verbs = ReferenceStore(context: context).fetchVerbs()
|
||||
print("[VerbListView] loaded \(verbs.count) verbs (container: \(ObjectIdentifier(container)))")
|
||||
irregularityByVerbId = buildIrregularityIndex(context: context)
|
||||
print("[VerbListView] loaded \(verbs.count) verbs, \(irregularityByVerbId.count) flagged irregular (container: \(ObjectIdentifier(container)))")
|
||||
}
|
||||
|
||||
// MARK: - Level filter (shared with Practice via UserProgress)
|
||||
|
||||
private var levelChipLabel: String {
|
||||
let names = selectedLevels
|
||||
.sorted { $0.rawValue < $1.rawValue }
|
||||
.map(\.displayName)
|
||||
if names.isEmpty { return "No levels" }
|
||||
if names.count == 1 { return names[0] }
|
||||
return "\(names.count) levels"
|
||||
}
|
||||
|
||||
private func loadProgress() {
|
||||
progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||
}
|
||||
|
||||
private func toggleLevel(_ level: VerbLevel) {
|
||||
guard let progress else { return }
|
||||
let enabled = !progress.selectedVerbLevels.contains(level)
|
||||
progress.setLevelEnabled(level, enabled: enabled)
|
||||
try? cloudContext.save()
|
||||
}
|
||||
|
||||
private func setAllLevels(enabled: Bool) {
|
||||
guard let progress else { return }
|
||||
if enabled {
|
||||
progress.selectedVerbLevels = Set(VerbLevel.allCases)
|
||||
} else {
|
||||
// Practice treats an empty set as "no verbs", so guard against
|
||||
// leaving the user with nothing — keep at least `basic`.
|
||||
progress.selectedVerbLevels = [.basic]
|
||||
}
|
||||
try? cloudContext.save()
|
||||
}
|
||||
|
||||
private func buildIrregularityIndex(context: ModelContext) -> [Int: Set<IrregularityCategory>] {
|
||||
let spans = (try? context.fetch(FetchDescriptor<IrregularSpan>())) ?? []
|
||||
var index: [Int: Set<IrregularityCategory>] = [:]
|
||||
for span in spans {
|
||||
let category: IrregularityCategory
|
||||
switch span.spanType {
|
||||
case 100..<200: category = .spelling
|
||||
case 200..<300: category = .stemChange
|
||||
case 300..<400: category = .uniqueIrregular
|
||||
default: continue
|
||||
}
|
||||
index[span.verbId, default: []].insert(category)
|
||||
}
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
struct VerbRowView: View {
|
||||
let verb: Verb
|
||||
var irregularities: Set<IrregularityCategory> = []
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
@@ -88,14 +284,39 @@ struct VerbRowView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(verb.level.prefix(3).uppercased())
|
||||
.font(.caption2)
|
||||
.fontWeight(.semibold)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(levelColor(verb.level).opacity(0.15))
|
||||
.foregroundStyle(levelColor(verb.level))
|
||||
.clipShape(Capsule())
|
||||
HStack(spacing: 4) {
|
||||
ForEach(orderedIrregularities, id: \.self) { cat in
|
||||
Image(systemName: cat.systemImage)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(irregularityColor(cat))
|
||||
.help(cat.rawValue)
|
||||
.accessibilityLabel(cat.rawValue)
|
||||
}
|
||||
|
||||
Text(verb.level.prefix(3).uppercased())
|
||||
.font(.caption2)
|
||||
.fontWeight(.semibold)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(levelColor(verb.level).opacity(0.15))
|
||||
.foregroundStyle(levelColor(verb.level))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var orderedIrregularities: [IrregularityCategory] {
|
||||
// Order: unique > stem > spelling (most notable first)
|
||||
let order: [IrregularityCategory] = [.uniqueIrregular, .stemChange, .spelling]
|
||||
return order.filter { irregularities.contains($0) }
|
||||
}
|
||||
|
||||
private func irregularityColor(_ category: IrregularityCategory) -> Color {
|
||||
switch category {
|
||||
case .uniqueIrregular: return .purple
|
||||
case .stemChange: return .orange
|
||||
case .spelling: return .teal
|
||||
case .anyIrregular: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,104 @@
|
||||
[
|
||||
{"infinitive": "aburrirse", "baseInfinitive": "aburrir", "english": "to get bored"},
|
||||
{"infinitive": "acercarse", "baseInfinitive": "acercar", "english": "to get close to", "usageHint": "a"},
|
||||
{"infinitive": "acordarse", "baseInfinitive": "acordar", "english": "to remember", "usageHint": "de"},
|
||||
{"infinitive": "acostarse", "baseInfinitive": "acostar", "english": "to lay down / to go to bed"},
|
||||
{"infinitive": "acostumbrarse", "baseInfinitive": "acostumbrar", "english": "to get used to", "usageHint": "a"},
|
||||
{"infinitive": "afeitarse", "baseInfinitive": "afeitar", "english": "to shave"},
|
||||
{"infinitive": "alegrarse", "baseInfinitive": "alegrar", "english": "to be glad / happy / pleased"},
|
||||
{"infinitive": "alejarse", "baseInfinitive": "alejar", "english": "to get away from", "usageHint": "de"},
|
||||
{"infinitive": "animarse", "baseInfinitive": "animar", "english": "to cheer up / to dare to do something", "usageHint": "a"},
|
||||
{"infinitive": "apurarse", "baseInfinitive": "apurar", "english": "to hurry"},
|
||||
{"infinitive": "aprovecharse", "baseInfinitive": "aprovechar", "english": "to take advantage of", "usageHint": "de"},
|
||||
{"infinitive": "asustarse", "baseInfinitive": "asustar", "english": "to get or become afraid"},
|
||||
{"infinitive": "atreverse", "baseInfinitive": "atrever", "english": "to dare to", "usageHint": "a"},
|
||||
{"infinitive": "bañarse", "baseInfinitive": "bañar", "english": "to take a bath / shower"},
|
||||
{"infinitive": "burlarse", "baseInfinitive": "burlar", "english": "to make fun of", "usageHint": "de"},
|
||||
{"infinitive": "caerse", "baseInfinitive": "caer", "english": "to fall down"},
|
||||
{"infinitive": "calmarse", "baseInfinitive": "calmar", "english": "to calm down"},
|
||||
{"infinitive": "cansarse", "baseInfinitive": "cansar", "english": "to get tired (of)", "usageHint": "(de)"},
|
||||
{"infinitive": "casarse", "baseInfinitive": "casar", "english": "to marry", "usageHint": "con"},
|
||||
{"infinitive": "cepillarse", "baseInfinitive": "cepillar", "english": "to brush (hair, teeth)"},
|
||||
{"infinitive": "deprimirse", "baseInfinitive": "deprimir", "english": "to become depressed"},
|
||||
{"infinitive": "conformarse", "baseInfinitive": "conformar", "english": "to resign oneself to", "usageHint": "con"},
|
||||
{"infinitive": "volverse", "baseInfinitive": "volver", "english": "to become / to turn into / to return"},
|
||||
{"infinitive": "darse", "baseInfinitive": "dar", "english": "to realize", "usageHint": "cuenta de"},
|
||||
{"infinitive": "dedicarse", "baseInfinitive": "dedicar", "english": "to dedicate oneself to / to do for a living", "usageHint": "a"},
|
||||
{"infinitive": "despedirse", "baseInfinitive": "despedir", "english": "to say goodbye", "usageHint": "(de)"},
|
||||
{"infinitive": "despertarse", "baseInfinitive": "despertar", "english": "to wake up"},
|
||||
{"infinitive": "desvestirse", "baseInfinitive": "desvestir", "english": "to undress"},
|
||||
{"infinitive": "dirigirse", "baseInfinitive": "dirigir", "english": "to go to / make one's way toward / to address", "usageHint": "a"},
|
||||
{"infinitive": "hacerse", "baseInfinitive": "hacer", "english": "to become / to pretend"},
|
||||
{"infinitive": "divertirse", "baseInfinitive": "divertir", "english": "to have fun"},
|
||||
{"infinitive": "dormirse", "baseInfinitive": "dormir", "english": "to fall asleep / to oversleep"},
|
||||
{"infinitive": "ducharse", "baseInfinitive": "duchar", "english": "to shower"},
|
||||
{"infinitive": "echarse", "baseInfinitive": "echar", "english": "to begin (usually suddenly) to do something / to break into", "usageHint": "a"},
|
||||
{"infinitive": "enamorarse", "baseInfinitive": "enamorar", "english": "to fall in love with", "usageHint": "de"},
|
||||
{"infinitive": "encargarse", "baseInfinitive": "encargar", "english": "to take charge of or be responsible for", "usageHint": "de"},
|
||||
{"infinitive": "encogerse", "baseInfinitive": "encoger", "english": "to shrug (shoulders)", "usageHint": "(de hombros)"},
|
||||
{"infinitive": "encontrarse", "baseInfinitive": "encontrar", "english": "to meet with / to run into someone", "usageHint": "(con)"},
|
||||
{"infinitive": "enfermarse", "baseInfinitive": "enfermar", "english": "to get sick"},
|
||||
{"infinitive": "enojarse", "baseInfinitive": "enojar", "english": "to get or become angry"},
|
||||
{"infinitive": "enterarse", "baseInfinitive": "enterar", "english": "to find out, to realize", "usageHint": "de"},
|
||||
{"infinitive": "exponerse", "baseInfinitive": "exponer", "english": "to expose oneself to or run the risk of", "usageHint": "a"},
|
||||
{"infinitive": "fijarse", "baseInfinitive": "fijar", "english": "to pay attention to / to take a look"},
|
||||
{"infinitive": "jugarse", "baseInfinitive": "jugar", "english": "to risk"},
|
||||
{"infinitive": "lastimarse", "baseInfinitive": "lastimar", "english": "to get hurt or hurt oneself"},
|
||||
{"infinitive": "lavarse", "baseInfinitive": "lavar", "english": "to wash (a body part)"},
|
||||
{"infinitive": "levantarse", "baseInfinitive": "levantar", "english": "to get up"},
|
||||
{"infinitive": "maquillarse", "baseInfinitive": "maquillar", "english": "to put makeup on"},
|
||||
{"infinitive": "meterse", "baseInfinitive": "meter", "english": "to get into / to pick on / to pick a fight with", "usageHint": "en / con"},
|
||||
{"infinitive": "motivarse", "baseInfinitive": "motivar", "english": "to become or get motivated to"},
|
||||
{"infinitive": "moverse", "baseInfinitive": "mover", "english": "to move oneself"},
|
||||
{"infinitive": "mudarse", "baseInfinitive": "mudar", "english": "to move (change residence)"},
|
||||
{"infinitive": "negarse", "baseInfinitive": "negar", "english": "to refuse to", "usageHint": "a"},
|
||||
{"infinitive": "obsesionarse", "baseInfinitive": "obsesionar", "english": "to be or get obsessed with", "usageHint": "con"},
|
||||
{"infinitive": "ocuparse", "baseInfinitive": "ocupar", "english": "to look after", "usageHint": "de"},
|
||||
{"infinitive": "olvidarse", "baseInfinitive": "olvidar", "english": "to forget", "usageHint": "de"},
|
||||
{"infinitive": "parecerse", "baseInfinitive": "parecer", "english": "to look like someone or something", "usageHint": "a"},
|
||||
{"infinitive": "peinarse", "baseInfinitive": "peinar", "english": "to comb your hair"},
|
||||
{"infinitive": "ponerse", "baseInfinitive": "poner", "english": "to put on (clothing) / to get or become"},
|
||||
{"infinitive": "ponerse", "baseInfinitive": "poner", "english": "to come to an agreement with someone", "usageHint": "de acuerdo"},
|
||||
{"infinitive": "preocuparse", "baseInfinitive": "preocupar", "english": "to worry about", "usageHint": "por"},
|
||||
{"infinitive": "prepararse", "baseInfinitive": "preparar", "english": "to prepare to"},
|
||||
{"infinitive": "probarse", "baseInfinitive": "probar", "english": "to try on"},
|
||||
{"infinitive": "quebrarse", "baseInfinitive": "quebrar", "english": "to break (an arm, leg, etc.)"},
|
||||
{"infinitive": "quejarse", "baseInfinitive": "quejar", "english": "to complain about", "usageHint": "de"},
|
||||
{"infinitive": "quedarse", "baseInfinitive": "quedar", "english": "to remain / to stay"},
|
||||
{"infinitive": "quemarse", "baseInfinitive": "quemar", "english": "to burn oneself / one's body"},
|
||||
{"infinitive": "quitarse", "baseInfinitive": "quitar", "english": "to take off (clothing, etc.)"},
|
||||
{"infinitive": "reírse", "baseInfinitive": "reír", "english": "to laugh about", "usageHint": "de"},
|
||||
{"infinitive": "resignarse", "baseInfinitive": "resignar", "english": "to resign oneself to", "usageHint": "a"},
|
||||
{"infinitive": "romperse", "baseInfinitive": "romper", "english": "to break (an arm, leg, etc.)"},
|
||||
{"infinitive": "secarse", "baseInfinitive": "secar", "english": "to dry (a body part)"},
|
||||
{"infinitive": "sentarse", "baseInfinitive": "sentar", "english": "to sit down"},
|
||||
{"infinitive": "sentirse", "baseInfinitive": "sentir", "english": "to feel"},
|
||||
{"infinitive": "servirse", "baseInfinitive": "servir", "english": "to help oneself to (food)"},
|
||||
{"infinitive": "suicidarse", "baseInfinitive": "suicidar", "english": "to commit suicide"},
|
||||
{"infinitive": "tratarse", "baseInfinitive": "tratar", "english": "to be about", "usageHint": "de"},
|
||||
{"infinitive": "vestirse", "baseInfinitive": "vestir", "english": "to get dressed"},
|
||||
{"infinitive": "marearse", "baseInfinitive": "marear", "english": "to get sick, to get dizzy"},
|
||||
{"infinitive": "irse", "baseInfinitive": "ir", "english": "to leave"},
|
||||
{"infinitive": "imaginarse", "baseInfinitive": "imaginar", "english": "to imagine"},
|
||||
{"infinitive": "preguntarse", "baseInfinitive": "preguntar", "english": "to wonder"},
|
||||
{"infinitive": "llamarse", "baseInfinitive": "llamar", "english": "to be called"},
|
||||
{"infinitive": "verse", "baseInfinitive": "ver", "english": "to look or appear"},
|
||||
{"infinitive": "distraerse", "baseInfinitive": "distraer", "english": "to get distracted"},
|
||||
{"infinitive": "concentrarse", "baseInfinitive": "concentrar", "english": "to focus"},
|
||||
{"infinitive": "rendirse", "baseInfinitive": "rendir", "english": "to give up"},
|
||||
{"infinitive": "relajarse", "baseInfinitive": "relajar", "english": "to relax"},
|
||||
{"infinitive": "merecerse", "baseInfinitive": "merecer", "english": "to deserve"},
|
||||
{"infinitive": "suponerse", "baseInfinitive": "suponer", "english": "to suppose"},
|
||||
{"infinitive": "conectarse", "baseInfinitive": "conectar", "english": "to connect"},
|
||||
{"infinitive": "destacarse", "baseInfinitive": "destacar", "english": "to stand out"},
|
||||
{"infinitive": "recibirse", "baseInfinitive": "recibir", "english": "to graduate"},
|
||||
{"infinitive": "graduarse", "baseInfinitive": "graduar", "english": "to graduate"},
|
||||
{"infinitive": "perderse", "baseInfinitive": "perder", "english": "to get lost"},
|
||||
{"infinitive": "cambiarse", "baseInfinitive": "cambiar", "english": "to change (clothing)", "usageHint": "(de ropa)"},
|
||||
{"infinitive": "adaptarse", "baseInfinitive": "adaptar", "english": "to adapt, to adjust", "usageHint": "a"},
|
||||
{"infinitive": "salirse", "baseInfinitive": "salir", "english": "to get away with", "usageHint": "con (la suya)"},
|
||||
{"infinitive": "subirse", "baseInfinitive": "subir", "english": "to get on (the bus, etc.)", "usageHint": "a"},
|
||||
{"infinitive": "tranquilizarse", "baseInfinitive": "tranquilizar", "english": "to relax"},
|
||||
{"infinitive": "equivocarse", "baseInfinitive": "equivocar", "english": "to get something wrong / confused"},
|
||||
{"infinitive": "confundirse", "baseInfinitive": "confundir", "english": "to get something wrong / confused"}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"note": "Curated YouTube videos per guide/grammar item for Issue #21. Each entry: {videoId, title}. Missing entries surface a 'No video yet' label in the app. TheLanguageTutor videos preferred where a matching lesson exists.",
|
||||
"tenseGuides": {
|
||||
"ind_presente": {"videoId": "8HWXJjxvOTE", "title": "Spanish Present Tense: Regular -AR -ER -IR verb conjugation"},
|
||||
"ind_preterito": {"videoId": "0PR7G81gKEc", "title": "Past Tense Verbs You Must Learn in Spanish — The Language Tutor Lesson 64"},
|
||||
"ind_imperfecto": {"videoId": "AmnTX30VliE", "title": "Mastering the Imperfect Tense in Spanish — The Language Tutor Lesson 50"},
|
||||
"ind_futuro": {"videoId": "U42loE1zhdw", "title": "Spanish Verbs in Future Tense — The Language Tutor Lesson 51"},
|
||||
"ind_perfecto": {"videoId": "-uHwV5Lu310", "title": "Present Perfect Indicative Tense in Spanish — The Language Tutor Lesson 55"},
|
||||
"ind_pluscuamperfecto": {"videoId": "T_M3h88BUUw", "title": "Understanding Past Perfect Indicative Tense in Spanish — The Language Tutor Lesson 57"},
|
||||
"ind_futuro_perfecto": {"videoId": "6cgc5ENNbR4", "title": "Understanding the Future Perfect Indicative in Spanish — The Language Tutor Lesson 62"},
|
||||
"ind_preterito_anterior": {"videoId": "OCCgeYLlqck", "title": "The Past Anterior Tense in Spanish — The Language Tutor Lesson 65"},
|
||||
"cond_presente": {"videoId": "nRaMf1Y1TCM", "title": "Understanding the Conditional Tense in Spanish — The Language Tutor Lesson 52"},
|
||||
"cond_perfecto": {"videoId": "nlF-8kg-xaM", "title": "Mastering the Conditional Perfect Tense in Spanish — The Language Tutor Lesson 63"},
|
||||
"subj_presente": {"videoId": "CRvXpo45oHw", "title": "The Subjunctive in Spanish — The Language Tutor Lesson 58"},
|
||||
"subj_imperfecto_1": {"videoId": "5VkCYZGvNlI", "title": "Imperfect Subjunctive in Spanish — The Language Tutor Lesson 59"},
|
||||
"subj_imperfecto_2": {"videoId": "5VkCYZGvNlI", "title": "Imperfect Subjunctive in Spanish — The Language Tutor Lesson 59"},
|
||||
"subj_perfecto": {"videoId": "Sm2DDq99Uzw", "title": "Unlock Fluent Spanish: Present Perfect Subjunctive Mastery! — The Language Tutor"},
|
||||
"subj_pluscuamperfecto_1": {"videoId": "am0YiYkTQ_E", "title": "Understanding the Past Perfect Subjunctive in Spanish — The Language Tutor Lesson 61"},
|
||||
"subj_pluscuamperfecto_2": {"videoId": "am0YiYkTQ_E", "title": "Understanding the Past Perfect Subjunctive in Spanish — The Language Tutor Lesson 61"},
|
||||
"subj_futuro": {"videoId": "YPWJsmD3hN4", "title": "Spanish Answers, Episode 10: Future Subjunctive"},
|
||||
"subj_futuro_perfecto": {"videoId": "9vmo2C-0iuQ", "title": "Free Spanish Lessons 151 - Spanish Subjunctive Tense: Future Perfect"},
|
||||
"imp_afirmativo": {"videoId": "C2UnO5khpi4", "title": "How to Make Commands in Spanish — The Language Tutor Lesson 54"},
|
||||
"imp_negativo": {"videoId": "C2UnO5khpi4", "title": "How to Make Commands in Spanish — The Language Tutor Lesson 54"}
|
||||
},
|
||||
"grammarNotes": {
|
||||
"ser-vs-estar": {"videoId": "tFXGCUi2yls", "title": "Ser vs Estar — BaseLang Spanish Lesson #3"},
|
||||
"por-vs-para": {"videoId": "02Am4LlPGjU", "title": "When to Use Por or Para in Spanish — The Language Tutor Lesson 47"},
|
||||
"preterite-vs-imperfect": {"videoId": "LvhO2-azzig", "title": "Spanish Past Tense: When to Use the Preterite vs Imperfect — BaseLang"},
|
||||
"subjunctive-triggers": {"videoId": "pMAMDgNNPB0", "title": "Spanish Subjunctive Simplified For Beginners — BaseLang"},
|
||||
"reflexive-verbs": {"videoId": "_uH_tosBLyo", "title": "Reflexive Verbs in Spanish — The Language Tutor Lesson 37"},
|
||||
"object-pronouns": {"videoId": "gEe4NW1FXx4", "title": "Intro To Spanish Direct And Indirect Object Pronouns — BaseLang"},
|
||||
"gustar-like-verbs": {"videoId": "SAfXpyZlz-I", "title": "How to Use Gustar in Spanish — The Language Tutor Lesson 121"},
|
||||
"comparatives-superlatives": {"videoId": "U74ClJsbfb0", "title": "Comparatives and Superlatives in Spanish — The Language Tutor Lesson 123"},
|
||||
"conditional-if-clauses": {"videoId": "thvW8qVsqkE", "title": "Si Clauses: The Spanish Hypothetical Explained — BaseLang"},
|
||||
"commands-imperative": {"videoId": "C2UnO5khpi4", "title": "How to Make Commands in Spanish — The Language Tutor Lesson 54"},
|
||||
"saber-vs-conocer": {"videoId": "j87i7MVCvIE", "title": "Saber vs. Conocer: Right (and WRONG) Times to Use These Spanish Verbs"},
|
||||
"double-negatives": {"videoId": "Y887wmI0O_o", "title": "Understanding Negation Words in Spanish — The Language Tutor Lesson 67"},
|
||||
"adjective-placement": {"videoId": "2B_TK_aun8E", "title": "Adjectives and Nouns Working Together in Spanish — The Language Tutor Lesson 66"},
|
||||
"tener-expressions": {"videoId": "189Qg68VCmo", "title": "The Verb Tener — BaseLang Spanish Lesson #17"},
|
||||
"personal-a": {"videoId": "B38CwLxgmOc", "title": "Learn Spanish — Preposition 'A' vs. Personal 'A'"},
|
||||
"relative-pronouns": {"videoId": "fnD1VaLpsCA", "title": "How to Use Relative Pronouns in Spanish — The Language Tutor Lesson 73"},
|
||||
"future-vs-ir-a": {"videoId": "2nxynOxr_h4", "title": "Future Tense in Spanish: 3 Ways To Speak About The Future — BaseLang"},
|
||||
"accent-marks-stress": {"videoId": "mXro8ngx07A", "title": "Spanish Accent Marks: When and How to Use Them — The Language Tutor"},
|
||||
"se-constructions": {"videoId": "ndxsrGD7b-8", "title": "Understanding 'SE' in Spanish: Reflexive, Passive, and Impersonal Constructions"},
|
||||
"estar-gerund-progressive": {"videoId": "s6HeVBv-ctM", "title": "How to Use Gerunds '-ing' in Spanish — The Language Tutor Lesson 113"},
|
||||
"spanish-suffixes": {"videoId": "o88gkstA0ds", "title": "Diminutives and Augmentatives in Spanish — The Language Tutor Lesson 78"},
|
||||
"common-irregular-verbs": {"videoId": "l0rOmomHSxg", "title": "Irregular Verbs in Spanish — The Language Tutor Lesson 21"},
|
||||
"types-of-irregular-verbs": {"videoId": "-XogD_S7pY4", "title": "Master IRREGULAR VERBS in the PRESENT TENSE | Complete Spanish Lesson"},
|
||||
"present-indicative-conjugation": {"videoId": "8HWXJjxvOTE", "title": "Spanish Present Tense: Regular -AR -ER -IR verb conjugation"},
|
||||
"articles-and-gender": {"videoId": "YeTIwDcKwZ4", "title": "Definite and Indefinite Articles in Spanish — The Language Tutor Lesson 11"},
|
||||
"possessive-adjectives": {"videoId": "Y4Aw1jy0ohE", "title": "Possessive Adjectives in Spanish — BaseLang"},
|
||||
"demonstrative-adjectives": {"videoId": "g4UzE8c2wik", "title": "How to Use This, These, That and Those in Spanish — The Language Tutor Lesson 56"},
|
||||
"greetings-farewells": {"videoId": "AqfQQZVmTUw", "title": "Every Spanish Greeting You Need (Formal, Casual & Slang) — The Language Tutor"},
|
||||
"poder-infinitive": {"videoId": "hCUbz5942EY", "title": "Spanish - The Verb 'Poder' Explained In 3 Minutes"},
|
||||
"al-del-contractions": {"videoId": "nWPZZWIwWxg", "title": "Spanish Contractions AL and DEL — The Language Tutor Lesson 15"},
|
||||
"prepositional-pronouns": {"videoId": "quzRXk0oKp8", "title": "Mastering Prepositional Pronouns in Spanish — The Language Tutor Lesson 71"},
|
||||
"irregular-yo-verbs": {"videoId": "bM9NLgaeUvw", "title": "What are YO GO verbs in Spanish? — BaseLang"},
|
||||
"stem-changing-verbs": {"videoId": "WB2ThQauaWo", "title": "Stem Changing Verbs in Spanish: Explained For Beginners — BaseLang"},
|
||||
"stressed-possessives": {"videoId": "OL86D_omkSQ", "title": "Learning Possessive Pronouns in Spanish — The Language Tutor Lesson 68"},
|
||||
"present-perfect-tense": {"videoId": "-uHwV5Lu310", "title": "Present Perfect Indicative Tense in Spanish — The Language Tutor Lesson 55"},
|
||||
"future-perfect-tense": {"videoId": "6cgc5ENNbR4", "title": "Understanding the Future Perfect Indicative in Spanish — The Language Tutor Lesson 62"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# Curated YouTube Videos
|
||||
|
||||
Every tense guide and grammar note in the app can be tied to a single curated YouTube video. This file is generated from `Conjuga/youtube_videos.json` by `Scripts/generate_videos_markdown.py` — regenerate when you add or change entries.
|
||||
|
||||
- Total tense-guide entries: **20** of 20
|
||||
- Total grammar-note entries: **36** of 36
|
||||
- Last verified: **2026-04-23** (run `python3 Scripts/generate_videos_markdown.py` to refresh)
|
||||
|
||||
Like counts are often blank because YouTube hides the public count on most videos for signed-out requests. Titles and durations are pulled live from YouTube; unavailable entries mean the video has been taken down, made private, or region-locked. A few entries marked "not available on this app" are a transient yt-dlp extraction limit — the video itself still plays fine when tapping Stream in the app.
|
||||
|
||||
## Tense guides
|
||||
|
||||
Tied to `TenseGuide.tenseId` in the Guide tab.
|
||||
|
||||
| Tense ID | Title | Channel | Uploaded | Duration | Views | Likes | URL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `ind_presente` | Spanish Present Tense: Regular -AR -ER -IR verb conjugation | Learn Spanish with Spanish55 | 2019-10-18 | 7:14 | 51,288 | 233 | [watch](https://www.youtube.com/watch?v=8HWXJjxvOTE) |
|
||||
| `ind_preterito` | Past Tense Verbs You Must Learn in Spanish \| The Language Tutor *Lesson 64* | The Language Tutor - Spanish | 2020-04-05 | 9:03 | 50,837 | 1,433 | [watch](https://www.youtube.com/watch?v=0PR7G81gKEc) |
|
||||
| `ind_imperfecto` | Mastering the Imperfect Tense in Spanish \| The Language Tutor *Lesson 50* | The Language Tutor - Spanish | 2019-12-22 | 19:43 | 385,678 | 8,420 | [watch](https://www.youtube.com/watch?v=AmnTX30VliE) |
|
||||
| `ind_futuro` | Spanish Verbs in Future Tense \| The Language Tutor *Lesson 51* | The Language Tutor - Spanish | 2019-12-30 | 13:58 | 389,408 | 9,542 | [watch](https://www.youtube.com/watch?v=U42loE1zhdw) |
|
||||
| `ind_perfecto` | Present Perfect Indicative Tense in Spanish \| The Language Tutor *Lesson 55* | The Language Tutor - Spanish | 2020-01-29 | 18:09 | 273,176 | 6,676 | [watch](https://www.youtube.com/watch?v=-uHwV5Lu310) |
|
||||
| `ind_pluscuamperfecto` | Understanding Past Perfect Indicative Tense in Spanish \| The Language Tutor *Lesson 57* | The Language Tutor - Spanish | 2020-02-09 | 7:46 | 132,330 | 3,400 | [watch](https://www.youtube.com/watch?v=T_M3h88BUUw) |
|
||||
| `ind_futuro_perfecto` | Understanding the Future Perfect Indicative in Spanish \| The Language Tutor *Lesson 62* | The Language Tutor - Spanish | 2020-03-22 | 7:59 | 58,757 | 1,547 | [watch](https://www.youtube.com/watch?v=6cgc5ENNbR4) |
|
||||
| `ind_preterito_anterior` | The Past Anterior Tense in Spanish \| The Language Tutor *Lesson 65* | The Language Tutor - Spanish | 2020-04-12 | 8:37 | 45,389 | 1,124 | [watch](https://www.youtube.com/watch?v=OCCgeYLlqck) |
|
||||
| `cond_presente` | Understanding the Conditional Tense in Spanish \| The Language Tutor *Lesson 52* | The Language Tutor - Spanish | 2020-01-05 | 10:01 | 222,400 | 5,338 | [watch](https://www.youtube.com/watch?v=nRaMf1Y1TCM) |
|
||||
| `cond_perfecto` | Mastering the Conditional Perfect Tense in Spanish \| The Language Tutor *Lesson 63* | The Language Tutor - Spanish | 2020-03-29 | 6:07 | 44,304 | 1,300 | [watch](https://www.youtube.com/watch?v=nlF-8kg-xaM) |
|
||||
| `subj_presente` | The Subjunctive in Spanish \| The Language Tutor *Lesson 58* | The Language Tutor - Spanish | 2020-02-23 | 16:30 | 424,782 | 11,272 | [watch](https://www.youtube.com/watch?v=CRvXpo45oHw) |
|
||||
| `subj_imperfecto_1` | Imperfect Subjunctive in Spanish \| The Language Tutor *Lesson 59* | The Language Tutor - Spanish | 2020-03-01 | 18:04 | 194,900 | 4,226 | [watch](https://www.youtube.com/watch?v=5VkCYZGvNlI) |
|
||||
| `subj_imperfecto_2` | Imperfect Subjunctive in Spanish \| The Language Tutor *Lesson 59* | The Language Tutor - Spanish | 2020-03-01 | 18:04 | 194,900 | 4,226 | [watch](https://www.youtube.com/watch?v=5VkCYZGvNlI) |
|
||||
| `subj_perfecto` | Unlock Fluent Spanish: Present Perfect Subjunctive Mastery! | The Language Tutor - Spanish | 2020-03-08 | 6:47 | 89,060 | 2,105 | [watch](https://www.youtube.com/watch?v=Sm2DDq99Uzw) |
|
||||
| `subj_pluscuamperfecto_1` | Understanding the Past Perfect Subjunctive in Spanish \| The Language Tutor *Lesson 61* | The Language Tutor - Spanish | 2020-03-15 | 8:51 | 75,462 | 1,862 | [watch](https://www.youtube.com/watch?v=am0YiYkTQ_E) |
|
||||
| `subj_pluscuamperfecto_2` | Understanding the Past Perfect Subjunctive in Spanish \| The Language Tutor *Lesson 61* | The Language Tutor - Spanish | 2020-03-15 | 8:51 | 75,462 | 1,862 | [watch](https://www.youtube.com/watch?v=am0YiYkTQ_E) |
|
||||
| `subj_futuro` | Spanish Answers, Episode 10: Future Subjunctive | Spanish Answers | 2019-05-14 | 12:52 | 2,580 | 60 | [watch](https://www.youtube.com/watch?v=YPWJsmD3hN4) |
|
||||
| `subj_futuro_perfecto` | Free Spanish Lessons 151-Spanish Subjunctive tense:Future Perfect-Video 1/2 | Aprender Idiomas y Cultura General con Rodrigo | 2014-05-18 | 10:00 | 2,025 | 20 | [watch](https://www.youtube.com/watch?v=9vmo2C-0iuQ) |
|
||||
| `imp_afirmativo` | How to Make Commands in Spanish \| The Language Tutor *Lesson 54* | The Language Tutor - Spanish | 2020-01-19 | 16:50 | 307,013 | 7,142 | [watch](https://www.youtube.com/watch?v=C2UnO5khpi4) |
|
||||
| `imp_negativo` | How to Make Commands in Spanish \| The Language Tutor *Lesson 54* | The Language Tutor - Spanish | 2020-01-19 | 16:50 | 307,013 | 7,142 | [watch](https://www.youtube.com/watch?v=C2UnO5khpi4) |
|
||||
|
||||
## Grammar notes
|
||||
|
||||
Tied to `GrammarNote.id` (hand-authored + generated) in the Guide → Grammar tab.
|
||||
|
||||
| Grammar ID | Title | Channel | Uploaded | Duration | Views | Likes | URL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `ser-vs-estar` | Ser vs Estar: Spanish Lesson #3 | BaseLang | 2016-04-15 | 4:38 | 129,144 | 785 | [watch](https://www.youtube.com/watch?v=tFXGCUi2yls) |
|
||||
| `por-vs-para` | When to Use Por or Para in Spanish \| The Language Tutor *Lesson 47* | The Language Tutor - Spanish | 2019-12-01 | 14:01 | 261,212 | 7,552 | [watch](https://www.youtube.com/watch?v=02Am4LlPGjU) |
|
||||
| `preterite-vs-imperfect` | Spanish Past Tense: When to Use the Preterite vs Imperfect | BaseLang | 2025-01-14 | 4:20 | 37,948 | — | [watch](https://www.youtube.com/watch?v=LvhO2-azzig) |
|
||||
| `subjunctive-triggers` | Spanish Subjunctive Simplified For Beginners | BaseLang | 2022-01-14 | 9:58 | 72,998 | — | [watch](https://www.youtube.com/watch?v=pMAMDgNNPB0) |
|
||||
| `reflexive-verbs` | Reflexive Verbs in Spanish \| The Language Tutor *Lesson 37* | The Language Tutor - Spanish | 2019-09-22 | 10:37 | 324,262 | 7,345 | [watch](https://www.youtube.com/watch?v=_uH_tosBLyo) |
|
||||
| `object-pronouns` | Intro To Spanish Direct And Indirect Object Pronouns | BaseLang | 2022-01-12 | 9:44 | 169,076 | — | [watch](https://www.youtube.com/watch?v=gEe4NW1FXx4) |
|
||||
| `gustar-like-verbs` | How to Use Gustar in Spanish \| The Language Tutor *Lesson 121* | The Language Tutor - Spanish | 2021-12-12 | 14:06 | 173,299 | — | [watch](https://www.youtube.com/watch?v=SAfXpyZlz-I) |
|
||||
| `comparatives-superlatives` | Comparatives and Superlatives in Spanish \| The Language Tutor *Lesson 123* | The Language Tutor - Spanish | 2022-05-10 | 15:57 | 90,246 | 2,085 | [watch](https://www.youtube.com/watch?v=U74ClJsbfb0) |
|
||||
| `conditional-if-clauses` | Si Clauses: The Spanish Hypothetical Explained | BaseLang | 2022-05-13 | 8:59 | 16,328 | — | [watch](https://www.youtube.com/watch?v=thvW8qVsqkE) |
|
||||
| `commands-imperative` | How to Make Commands in Spanish \| The Language Tutor *Lesson 54* | The Language Tutor - Spanish | 2020-01-19 | 16:50 | 307,013 | 7,142 | [watch](https://www.youtube.com/watch?v=C2UnO5khpi4) |
|
||||
| `saber-vs-conocer` | _(unavailable — Private video)_ | — | — | — | — | — | [watch](https://www.youtube.com/watch?v=j87i7MVCvIE) |
|
||||
| `double-negatives` | Understanding Negation Words in Spanish \| The Language Tutor *Lesson 67* | The Language Tutor - Spanish | 2020-04-26 | 21:36 | 150,024 | 3,888 | [watch](https://www.youtube.com/watch?v=Y887wmI0O_o) |
|
||||
| `adjective-placement` | Adjectives and Nouns Working Together in Spanish \| The Language Tutor *Lesson 66* | The Language Tutor - Spanish | 2020-04-19 | 17:17 | 70,340 | 1,980 | [watch](https://www.youtube.com/watch?v=2B_TK_aun8E) |
|
||||
| `tener-expressions` | The Verb Tener: Spanish Lesson #17 | BaseLang | 2016-05-03 | 4:16 | 7,948 | 76 | [watch](https://www.youtube.com/watch?v=189Qg68VCmo) |
|
||||
| `personal-a` | Learn Spanish-Preposition "A" vs. Personal “A” | castellano4U | 2017-04-04 | 11:45 | 1,976 | — | [watch](https://www.youtube.com/watch?v=B38CwLxgmOc) |
|
||||
| `relative-pronouns` | How to Use Relative Pronouns in Spanish \| The Language Tutor *Lesson 73* | The Language Tutor - Spanish | 2020-06-07 | 11:02 | 57,363 | — | [watch](https://www.youtube.com/watch?v=fnD1VaLpsCA) |
|
||||
| `future-vs-ir-a` | Future Tense in Spanish: 3 Ways To Speak About The Future | BaseLang | 2022-01-05 | 9:57 | 44,300 | — | [watch](https://www.youtube.com/watch?v=2nxynOxr_h4) |
|
||||
| `accent-marks-stress` | Spanish Accent Marks: When and How to Use Them | The Language Tutor - Spanish | 2020-06-14 | 14:09 | 98,134 | 3,242 | [watch](https://www.youtube.com/watch?v=mXro8ngx07A) |
|
||||
| `se-constructions` | Understanding "SE" in Spanish: Reflexive, Passive, and Impersonal Constructions EXPLAINED | Learn Spanish with Spanish55 | 2024-08-16 | 3:57 | 1,271 | 61 | [watch](https://www.youtube.com/watch?v=ndxsrGD7b-8) |
|
||||
| `estar-gerund-progressive` | How to Use Gerunds "-ing" in Spanish \| The Language Tutor *Lesson 113* | The Language Tutor - Spanish | 2021-06-20 | 10:29 | 54,552 | 2,091 | [watch](https://www.youtube.com/watch?v=s6HeVBv-ctM) |
|
||||
| `spanish-suffixes` | Diminutives and Augmentatives in Spanish \| The Language Tutor *Lesson 78* | The Language Tutor - Spanish | 2020-07-12 | 17:03 | 42,033 | — | [watch](https://www.youtube.com/watch?v=o88gkstA0ds) |
|
||||
| `common-irregular-verbs` | Irregular Verbs in Spanish \| The Language Tutor *Lesson 21* | The Language Tutor - Spanish | 2019-06-09 | 12:21 | 301,865 | 6,338 | [watch](https://www.youtube.com/watch?v=l0rOmomHSxg) |
|
||||
| `types-of-irregular-verbs` | _(unavailable — The following content is not available on this app)_ | — | — | — | — | — | [watch](https://www.youtube.com/watch?v=-XogD_S7pY4) |
|
||||
| `present-indicative-conjugation` | Spanish Present Tense: Regular -AR -ER -IR verb conjugation | Learn Spanish with Spanish55 | 2019-10-18 | 7:14 | 51,288 | 233 | [watch](https://www.youtube.com/watch?v=8HWXJjxvOTE) |
|
||||
| `articles-and-gender` | Definite and Indefinite Articles in Spanish \| The Language Tutor *Lesson 11* | The Language Tutor - Spanish | 2019-03-31 | 5:37 | 355,893 | 7,396 | [watch](https://www.youtube.com/watch?v=YeTIwDcKwZ4) |
|
||||
| `possessive-adjectives` | Possessive Adjectives in Spanish | BaseLang | 2022-06-27 | 13:40 | 15,925 | — | [watch](https://www.youtube.com/watch?v=Y4Aw1jy0ohE) |
|
||||
| `demonstrative-adjectives` | How to Use This, These, That and Those in Spanish \| The Language Tutor *Lesson 56* | The Language Tutor - Spanish | 2020-02-02 | 13:34 | 185,671 | 6,689 | [watch](https://www.youtube.com/watch?v=g4UzE8c2wik) |
|
||||
| `greetings-farewells` | Every Spanish Greeting You Need (Formal, Casual & Slang) | The Language Tutor - Spanish | 2019-03-10 | 9:54 | 673,751 | 17,161 | [watch](https://www.youtube.com/watch?v=AqfQQZVmTUw) |
|
||||
| `poder-infinitive` | Spanish - The Verb “Poder“ Explained In 3 Minutes | TheLanguageBro | 2023-07-01 | 3:16 | 4,913 | 106 | [watch](https://www.youtube.com/watch?v=hCUbz5942EY) |
|
||||
| `al-del-contractions` | Spanish Contractions AL and DEL \| The Language Tutor * Lesson 15 * | The Language Tutor - Spanish | 2019-04-28 | 3:34 | 211,085 | 5,457 | [watch](https://www.youtube.com/watch?v=nWPZZWIwWxg) |
|
||||
| `prepositional-pronouns` | Mastering Prepositional Pronouns in Spanish \| The Language Tutor *Lesson 71* | The Language Tutor - Spanish | 2020-05-24 | 13:34 | 93,929 | — | [watch](https://www.youtube.com/watch?v=quzRXk0oKp8) |
|
||||
| `irregular-yo-verbs` | What are YO GO verbs in Spanish? | BaseLang | 2022-04-21 | 5:08 | 17,894 | — | [watch](https://www.youtube.com/watch?v=bM9NLgaeUvw) |
|
||||
| `stem-changing-verbs` | Stem Changing Verbs in Spanish: Explained For Beginners | BaseLang | 2022-05-17 | 7:31 | 59,959 | — | [watch](https://www.youtube.com/watch?v=WB2ThQauaWo) |
|
||||
| `stressed-possessives` | Learning Possessive Pronouns in Spanish \| The Language Tutor *Lesson 68* | The Language Tutor - Spanish | 2020-05-03 | 10:01 | 129,223 | 3,671 | [watch](https://www.youtube.com/watch?v=OL86D_omkSQ) |
|
||||
| `present-perfect-tense` | Present Perfect Indicative Tense in Spanish \| The Language Tutor *Lesson 55* | The Language Tutor - Spanish | 2020-01-29 | 18:09 | 273,176 | 6,676 | [watch](https://www.youtube.com/watch?v=-uHwV5Lu310) |
|
||||
| `future-perfect-tense` | Understanding the Future Perfect Indicative in Spanish \| The Language Tutor *Lesson 62* | The Language Tutor - Spanish | 2020-03-22 | 7:59 | 58,757 | 1,547 | [watch](https://www.youtube.com/watch?v=6cgc5ENNbR4) |
|
||||
@@ -0,0 +1,95 @@
|
||||
import XCTest
|
||||
|
||||
/// Screenshot every chapter of the textbook — one top + one bottom frame each —
|
||||
/// so you can visually audit parsing / rendering issues across all 30 chapters.
|
||||
final class AllChaptersScreenshotTests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = true
|
||||
}
|
||||
|
||||
func testScreenshotEveryChapter() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments += ["-onboardingComplete", "YES"]
|
||||
app.launch()
|
||||
|
||||
let courseTab = app.tabBars.buttons["Course"]
|
||||
XCTAssertTrue(courseTab.waitForExistence(timeout: 5))
|
||||
courseTab.tap()
|
||||
|
||||
let textbookRow = app.buttons.containing(NSPredicate(
|
||||
format: "label CONTAINS[c] 'Complete Spanish'"
|
||||
)).firstMatch
|
||||
XCTAssertTrue(textbookRow.waitForExistence(timeout: 5))
|
||||
textbookRow.tap()
|
||||
|
||||
// NOTE: SwiftUI List preserves scroll position across navigation pushes,
|
||||
// so visiting chapters in-order means the next one is already visible
|
||||
// after we return from the previous one. No need to reset.
|
||||
attach(app, name: "00-chapter-list-top")
|
||||
|
||||
for chapter in 1...30 {
|
||||
guard let row = findChapterRow(app: app, chapter: chapter) else {
|
||||
XCTFail("Chapter \(chapter) row not reachable")
|
||||
continue
|
||||
}
|
||||
row.tap()
|
||||
|
||||
// Chapter body — wait until the chapter's title appears as a nav bar label
|
||||
_ = app.navigationBars.firstMatch.waitForExistence(timeout: 3)
|
||||
|
||||
attach(app, name: String(format: "ch%02d-top", chapter))
|
||||
// One big scroll to sample the bottom of the chapter
|
||||
dragFullScreen(app, direction: .up)
|
||||
dragFullScreen(app, direction: .up)
|
||||
attach(app, name: String(format: "ch%02d-bottom", chapter))
|
||||
|
||||
tapNavBack(app)
|
||||
// Small settle wait
|
||||
_ = app.navigationBars.firstMatch.waitForExistence(timeout: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private enum DragDirection { case up, down }
|
||||
|
||||
private func dragFullScreen(_ app: XCUIApplication, direction: DragDirection) {
|
||||
let top = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.12))
|
||||
let bot = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.88))
|
||||
switch direction {
|
||||
case .up: bot.press(forDuration: 0.1, thenDragTo: top)
|
||||
case .down: top.press(forDuration: 0.1, thenDragTo: bot)
|
||||
}
|
||||
}
|
||||
|
||||
private func findChapterRow(app: XCUIApplication, chapter: Int) -> XCUIElement? {
|
||||
// Chapter row accessibility label: "<n>, <title>, ..." (SwiftUI composes
|
||||
// label from inner Texts). Match by starting number.
|
||||
let predicate = NSPredicate(format: "label BEGINSWITH %@", "\(chapter),")
|
||||
let row = app.buttons.matching(predicate).firstMatch
|
||||
|
||||
if row.exists && row.isHittable { return row }
|
||||
|
||||
// Scroll down up to 8 times searching for the row — chapters visited
|
||||
// in order, so usually 0–2 swipes suffice.
|
||||
for _ in 0..<8 {
|
||||
if row.exists && row.isHittable { return row }
|
||||
dragFullScreen(app, direction: .up)
|
||||
}
|
||||
return row.exists ? row : nil
|
||||
}
|
||||
|
||||
private func tapNavBack(_ app: XCUIApplication) {
|
||||
let back = app.navigationBars.buttons.firstMatch
|
||||
if back.exists && back.isHittable { back.tap() }
|
||||
}
|
||||
|
||||
private func attach(_ app: XCUIApplication, name: String) {
|
||||
let screenshot = app.screenshot()
|
||||
let attachment = XCTAttachment(screenshot: screenshot)
|
||||
attachment.name = name
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import XCTest
|
||||
|
||||
final class StemChangeToggleTests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testStemChangeConjugationToggle() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments += ["-onboardingComplete", "YES"]
|
||||
app.launch()
|
||||
|
||||
// Course → LanGo Beginner I → Week 4 → E-IE stem-changing verbs
|
||||
app.tabBars.buttons["Course"].tap()
|
||||
|
||||
// Locate the E-IE deck row. Deck titles appear as static text / button.
|
||||
// Scroll until visible, then tap.
|
||||
let deckPredicate = NSPredicate(format: "label CONTAINS[c] 'E-IE stem changing verbs' AND NOT label CONTAINS[c] 'REVÉS'")
|
||||
let deckRow = app.buttons.matching(deckPredicate).firstMatch
|
||||
|
||||
let listRef = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85))
|
||||
let topRef = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.10))
|
||||
for _ in 0..<12 {
|
||||
if deckRow.exists && deckRow.isHittable { break }
|
||||
listRef.press(forDuration: 0.1, thenDragTo: topRef)
|
||||
}
|
||||
XCTAssertTrue(deckRow.waitForExistence(timeout: 3), "E-IE deck row missing")
|
||||
deckRow.tap()
|
||||
|
||||
attach(app, name: "01-deck-top")
|
||||
|
||||
// Tap "Show conjugation" on the first card
|
||||
let showBtn = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Show conjugation'")).firstMatch
|
||||
XCTAssertTrue(showBtn.waitForExistence(timeout: 3), "Show conjugation button missing")
|
||||
showBtn.tap()
|
||||
|
||||
// Wait for the conjugation rows + animation to settle.
|
||||
let yoLabel = app.staticTexts["yo"].firstMatch
|
||||
XCTAssertTrue(yoLabel.waitForExistence(timeout: 3), "yo row not rendered")
|
||||
// Give the transition time to complete before snapshotting.
|
||||
Thread.sleep(forTimeInterval: 0.6)
|
||||
attach(app, name: "02-conjugation-open")
|
||||
|
||||
// Also confirm all expected person labels are rendered.
|
||||
for person in ["yo", "tú", "nosotros"] {
|
||||
XCTAssertTrue(
|
||||
app.staticTexts[person].firstMatch.exists,
|
||||
"missing conjugation row for \(person)"
|
||||
)
|
||||
}
|
||||
|
||||
// Tap again to hide
|
||||
let hideBtn = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Hide conjugation'")).firstMatch
|
||||
XCTAssertTrue(hideBtn.waitForExistence(timeout: 2))
|
||||
hideBtn.tap()
|
||||
}
|
||||
|
||||
private func attach(_ app: XCUIApplication, name: String) {
|
||||
let s = app.screenshot()
|
||||
let a = XCTAttachment(screenshot: s)
|
||||
a.name = name
|
||||
a.lifetime = .keepAlways
|
||||
add(a)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import XCTest
|
||||
|
||||
final class TextbookFlowUITests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testTextbookFlow() throws {
|
||||
let app = XCUIApplication()
|
||||
// Skip onboarding via defaults (already set by run script, but harmless to override)
|
||||
app.launchArguments += ["-onboardingComplete", "YES"]
|
||||
app.launch()
|
||||
|
||||
// Dashboard should be default tab. Switch to Course.
|
||||
let courseTab = app.tabBars.buttons["Course"]
|
||||
XCTAssertTrue(courseTab.waitForExistence(timeout: 5), "Course tab missing")
|
||||
courseTab.tap()
|
||||
|
||||
// Attach a screenshot of the Course list
|
||||
attach(app, name: "01-course-list")
|
||||
|
||||
// Tap the Textbook entry
|
||||
let textbookRow = app.buttons.containing(NSPredicate(
|
||||
format: "label CONTAINS[c] 'Complete Spanish'"
|
||||
)).firstMatch
|
||||
XCTAssertTrue(textbookRow.waitForExistence(timeout: 5), "Textbook row missing in Course")
|
||||
textbookRow.tap()
|
||||
|
||||
attach(app, name: "02-textbook-chapter-list")
|
||||
|
||||
// Tap chapter 1 — should navigate to reader
|
||||
let chapterOneRow = app.buttons.containing(NSPredicate(
|
||||
format: "label CONTAINS[c] 'Nouns, Articles'"
|
||||
)).firstMatch
|
||||
XCTAssertTrue(chapterOneRow.waitForExistence(timeout: 5), "Chapter 1 row missing")
|
||||
chapterOneRow.tap()
|
||||
|
||||
attach(app, name: "03-chapter-body")
|
||||
|
||||
// Find the first exercise link ("Exercise 1.1")
|
||||
let exerciseRow = app.buttons.containing(NSPredicate(
|
||||
format: "label CONTAINS[c] 'Exercise 1.1'"
|
||||
)).firstMatch
|
||||
XCTAssertTrue(exerciseRow.waitForExistence(timeout: 5), "Exercise 1.1 link missing")
|
||||
exerciseRow.tap()
|
||||
|
||||
attach(app, name: "04-exercise-view")
|
||||
|
||||
// Check presence of input fields: at least a few numbered prompts
|
||||
// Text fields use SwiftUI placeholder "Your answer"
|
||||
let firstField = app.textFields["Your answer"].firstMatch
|
||||
XCTAssertTrue(firstField.waitForExistence(timeout: 5), "No input fields rendered for exercise")
|
||||
firstField.tap()
|
||||
firstField.typeText("el")
|
||||
|
||||
attach(app, name: "05-exercise-typed-el")
|
||||
|
||||
// Tap Check answers
|
||||
let checkButton = app.buttons["Check answers"]
|
||||
XCTAssertTrue(checkButton.waitForExistence(timeout: 3), "Check answers button missing")
|
||||
checkButton.tap()
|
||||
|
||||
attach(app, name: "06-exercise-graded")
|
||||
|
||||
// The first answer to Exercise 1.1 is "el" — we should see the first prompt
|
||||
// graded correct. Iterating too deeply is fragile; just take a screenshot
|
||||
// and check for presence of either a checkmark-like label or "Try again".
|
||||
let tryAgain = app.buttons["Try again"]
|
||||
XCTAssertTrue(tryAgain.waitForExistence(timeout: 3), "Grading did not complete")
|
||||
}
|
||||
|
||||
private func attach(_ app: XCUIApplication, name: String) {
|
||||
let screenshot = app.screenshot()
|
||||
let attachment = XCTAttachment(screenshot: screenshot)
|
||||
attachment.name = name
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import XCTest
|
||||
|
||||
final class VocabGridTests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
/// Verifies the chapter reader renders vocab tables as a paired Spanish↔English grid.
|
||||
func testChapter4VocabGrid() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments += ["-onboardingComplete", "YES"]
|
||||
app.launch()
|
||||
|
||||
app.tabBars.buttons["Course"].tap()
|
||||
|
||||
let textbookRow = app.buttons.containing(NSPredicate(
|
||||
format: "label CONTAINS[c] 'Complete Spanish'"
|
||||
)).firstMatch
|
||||
XCTAssertTrue(textbookRow.waitForExistence(timeout: 5))
|
||||
textbookRow.tap()
|
||||
|
||||
let ch4 = app.buttons["textbook-chapter-row-4"]
|
||||
XCTAssertTrue(ch4.waitForExistence(timeout: 3))
|
||||
ch4.tap()
|
||||
|
||||
attach(app, name: "01-ch4-top")
|
||||
|
||||
// Tap the first vocab disclosure — "Vocabulary (N items)"
|
||||
let vocabButton = app.buttons.matching(NSPredicate(
|
||||
format: "label BEGINSWITH 'Vocabulary ('"
|
||||
)).firstMatch
|
||||
XCTAssertTrue(vocabButton.waitForExistence(timeout: 3))
|
||||
vocabButton.tap()
|
||||
Thread.sleep(forTimeInterval: 0.4)
|
||||
|
||||
attach(app, name: "02-ch4-vocab-open")
|
||||
|
||||
// Scroll a little and screenshot a deeper vocab — numbers table is
|
||||
// typically a few screens down in chapter 4.
|
||||
app.swipeUp(velocity: .fast)
|
||||
app.swipeUp(velocity: .fast)
|
||||
attach(app, name: "03-ch4-deeper")
|
||||
}
|
||||
|
||||
private func attach(_ app: XCUIApplication, name: String) {
|
||||
let s = app.screenshot()
|
||||
let a = XCTAttachment(screenshot: s)
|
||||
a.name = name
|
||||
a.lifetime = .keepAlways
|
||||
add(a)
|
||||
}
|
||||
}
|
||||
@@ -41,21 +41,19 @@ struct CombinedProvider: TimelineProvider {
|
||||
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
|
||||
guard let localURL = SharedStore.localStoreURL() else { return nil }
|
||||
|
||||
// MUST declare all 6 local entities to match the main app's schema.
|
||||
// Declaring a subset would cause SwiftData to destructively migrate the store
|
||||
// on open, dropping the entities not listed here.
|
||||
// Open the store with the SAME schema as the main app. A subset schema
|
||||
// would make SwiftData destructively migrate the store on open and drop
|
||||
// every unlisted table (this is how widget refreshes kept wiping the
|
||||
// bundled Book rows, and TextbookChapter before them).
|
||||
let schema = Schema(SharedStore.localSchemaModels)
|
||||
let config = ModelConfiguration(
|
||||
"local",
|
||||
schema: Schema([
|
||||
Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
]),
|
||||
schema: schema,
|
||||
url: localURL,
|
||||
cloudKitDatabase: .none
|
||||
)
|
||||
guard let container = try? ModelContainer(
|
||||
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
for: schema,
|
||||
configurations: config
|
||||
) else { return nil }
|
||||
|
||||
|
||||
@@ -32,21 +32,19 @@ struct WordOfDayProvider: TimelineProvider {
|
||||
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
|
||||
guard let localURL = SharedStore.localStoreURL() else { return nil }
|
||||
|
||||
// MUST declare all 6 local entities to match the main app's schema.
|
||||
// Declaring a subset would cause SwiftData to destructively migrate the store
|
||||
// on open, dropping the entities not listed here.
|
||||
// Open the store with the SAME schema as the main app. A subset schema
|
||||
// would make SwiftData destructively migrate the store on open and drop
|
||||
// every unlisted table (this is how widget refreshes kept wiping the
|
||||
// bundled Book rows, and TextbookChapter before them).
|
||||
let schema = Schema(SharedStore.localSchemaModels)
|
||||
let config = ModelConfiguration(
|
||||
"local",
|
||||
schema: Schema([
|
||||
Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
]),
|
||||
schema: schema,
|
||||
url: localURL,
|
||||
cloudKitDatabase: .none
|
||||
)
|
||||
guard let container = try? ModelContainer(
|
||||
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
for: schema,
|
||||
configurations: config
|
||||
) else { return nil }
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
build/
|
||||
@@ -0,0 +1,102 @@
|
||||
# Books pipeline
|
||||
|
||||
Turns any EPUB into a chapter-structured JSON file the app bundles and reads.
|
||||
|
||||
## TL;DR
|
||||
|
||||
```bash
|
||||
cd Conjuga/Scripts/books
|
||||
./run.sh /path/to/book.epub --slug my-book-slug
|
||||
```
|
||||
|
||||
This runs Phase 1 (extract) and Phase 2 (manifest jobs), then stops and tells you how many translation jobs are pending. Run those via Claude Code subagents (Phase 2.5 below), then re-run `./run.sh` to bundle the final file.
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Script | What it does | Output |
|
||||
|---|---|---|---|
|
||||
| 1 | `extract_epub.py` | Unzip the EPUB, walk `content.opf` spine + `toc.ncx` navMap, group HTML files into chapters, strip HTML→text. | `build/<slug>/chapters.json` |
|
||||
| 2 | `translate_chapters.py` | Split each chapter into ~30-paragraph translation batches. Each batch becomes a job with its own input/output file. **Resumable**: jobs whose output file already exists are skipped. | `build/<slug>/jobs/<jobid>.input.json` + `_pending.txt` |
|
||||
| 2b | `build_glossary.py` | Tokenize every Spanish paragraph the same way the app does, collect the distinct words with example sentences, split into ~150-word glossary batches. **Resumable** the same way. | `build/<slug>/glossary/<jobid>.input.json` + `_pending.txt` |
|
||||
| 2.5 | Claude Code subagents | Drain **both** manifests: translate the chapter jobs *and* the glossary jobs, writing each job's `<jobid>.output.json`. See "Running translations" below. | `build/<slug>/{jobs,glossary}/<jobid>.output.json` |
|
||||
| 3 | `bundle_book.py` | Merge `chapters.json` + every translation `*.output.json` + every glossary `*.output.json` into the final bundled JSON the app reads. | `Conjuga/Conjuga/book_<slug>.json` |
|
||||
|
||||
`run.sh` chains 1 → 2 → 2b → 3. If Phase 2 or 2b produces pending jobs, Phase 3 still runs but bundles with placeholders so you can preview app structure before the LLM passes complete. Re-running `run.sh` after subagents fill in the outputs gives you the real bundled file.
|
||||
|
||||
The glossary is the book reader's primary word-lookup source: every distinct word translated once, in context, so taps are instant, cover the whole book, and don't mis-resolve homographs (e.g. "como" as the conjunction vs. the verb *comer*). This phase is a permanent part of the pipeline — every book imported this way gets a glossary.
|
||||
|
||||
## Adding a new book
|
||||
|
||||
1. **Drop the EPUB** anywhere on disk.
|
||||
2. **Run Phase 1+2**:
|
||||
```bash
|
||||
cd Conjuga/Scripts/books
|
||||
./run.sh /path/to/book.epub --slug my-book
|
||||
```
|
||||
Sanity-check the chapter list it prints. If chapter grouping looks wrong (e.g. an EPUB without a usable `toc.ncx`), `extract_epub.py` will need a fallback heuristic — see "Open assumptions" below.
|
||||
|
||||
3. **Run translations** (Phase 2.5). The default approach is to spawn Claude Code subagents from inside a Claude Code session pointed at this repo:
|
||||
|
||||
There are **two** manifests to drain — translation and glossary:
|
||||
- `build/<slug>/jobs/_pending.txt` with prompt `build/<slug>/jobs/_prompt_template.md`
|
||||
- `build/<slug>/glossary/_pending.txt` with prompt `build/<slug>/glossary/_prompt_template.md`
|
||||
|
||||
For each pending job ID, hand a subagent the matching prompt with `<JOB_INPUT_PATH>` / `<JOB_OUTPUT_PATH>` filled in. The subagent reads the input, produces the translation/glossary, and writes the output. Resumable — interrupted runs just leave the missing job IDs in `_pending.txt`.
|
||||
|
||||
Cluster jobs into agent batches of ~5–10 jobs each to keep per-agent context manageable. ~5 parallel agents is a good throughput target.
|
||||
|
||||
4. **Bundle**:
|
||||
```bash
|
||||
./run.sh /path/to/book.epub --slug my-book # re-running pulls in the new outputs
|
||||
# or directly:
|
||||
python3 bundle_book.py my-book --require-all
|
||||
```
|
||||
`--require-all` will fail loudly if any job is still missing.
|
||||
|
||||
5. **Bump `bookDataVersion`** in `DataLoader.swift` so the in-app store re-seeds the new book on next launch (or any time you re-run with new translations).
|
||||
|
||||
6. **Verify the file is bundled** in `Conjuga.xcodeproj`. The script writes `book_<slug>.json` into `Conjuga/Conjuga/Resources/`; if that folder is part of a recursive group reference, Xcode picks it up automatically. Otherwise, add it manually or via the `xcodeproj` ruby gem.
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
Conjuga/Scripts/books/
|
||||
├── extract_epub.py # Phase 1
|
||||
├── translate_chapters.py # Phase 2
|
||||
├── build_glossary.py # Phase 2b
|
||||
├── bundle_book.py # Phase 3
|
||||
├── run.sh # Orchestrator
|
||||
└── build/ # gitignored
|
||||
└── <slug>/
|
||||
├── chapters.json
|
||||
├── jobs/ # translation jobs
|
||||
│ ├── _pending.txt
|
||||
│ ├── _prompt_template.md
|
||||
│ ├── ch01_b00.input.json
|
||||
│ ├── ch01_b00.output.json
|
||||
│ └── ...
|
||||
└── glossary/ # glossary jobs (Phase 2b)
|
||||
├── _pending.txt
|
||||
├── _prompt_template.md
|
||||
├── gloss_b00.input.json
|
||||
├── gloss_b00.output.json
|
||||
└── ...
|
||||
```
|
||||
|
||||
The final output (`book_<slug>.json`) lives at `Conjuga/Conjuga/book_<slug>.json` so the iOS app bundle includes it. (Existing `textbook_data.json` / `conjuga_data.json` use the same layout — files in the app target root rather than a Resources subgroup.)
|
||||
|
||||
## Open assumptions
|
||||
|
||||
- **TOC drives chapter boundaries.** If an EPUB ships without a usable `toc.ncx`, or the navMap is too granular (e.g. one navPoint per page), `extract_epub.py` will need a fallback that groups by `<h1>` headings in spine order.
|
||||
- **Spanish bold tags = inline emphasis.** The Olly Richards books bold vocab hints inside paragraphs. We strip the bold and let the in-app dictionary lookup handle definitions instead. If a future book uses bold for something else (titles, etc.), revisit.
|
||||
- **Translation is per-paragraph 1:1.** Subagents must preserve paragraph count and order. `bundle_book.py` will warn + pad/truncate if a job's output array length doesn't match its input — but that's a sign the subagent misbehaved.
|
||||
|
||||
## Out of scope (intentional)
|
||||
|
||||
- OCR of vocab image tables (use `Scripts/textbook/` if your book is image-heavy).
|
||||
- Exercise extraction (textbook pipeline).
|
||||
- Per-occurrence word sense disambiguation. The glossary has one entry per
|
||||
distinct word, translated in context; a word genuinely used in two senses in
|
||||
the same book gets its dominant sense. The runtime `DictionaryService` + the
|
||||
on-device LLM remain as fallbacks for anything the glossary misses.
|
||||
- Cover image extraction (covers are derived from a color hash in the app for now).
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phase 2b — build a per-book glossary job manifest.
|
||||
|
||||
Scans chapters.json, tokenizes every Spanish paragraph the SAME way the iOS app
|
||||
does (whitespace split, lowercase, strip leading/trailing punctuation), collects
|
||||
the distinct words with a few example sentences each, and writes batched
|
||||
glossary jobs that Claude Code subagents can translate in parallel. Resumable:
|
||||
jobs whose output file already exists are skipped.
|
||||
|
||||
Usage:
|
||||
python3 build_glossary.py <slug> [--batch-size N] [--max-examples N]
|
||||
[--build BUILD_DIR]
|
||||
|
||||
Inputs:
|
||||
BUILD_DIR/<slug>/chapters.json (from extract_epub.py)
|
||||
|
||||
Outputs:
|
||||
BUILD_DIR/<slug>/glossary/<jobid>.input.json (one per batch — read by subagents)
|
||||
BUILD_DIR/<slug>/glossary/_pending.txt (job IDs still missing output)
|
||||
BUILD_DIR/<slug>/glossary/_prompt_template.md (prompt for each subagent)
|
||||
|
||||
Job input shape (.input.json):
|
||||
{"jobId": "gloss_b00",
|
||||
"words": [{"word": "taza", "examples": ["...", "..."]}, ...]}
|
||||
|
||||
Subagents must write <jobid>.output.json with shape:
|
||||
{"jobId": "gloss_b00",
|
||||
"entries": [{"word": "taza", "baseForm": "taza",
|
||||
"english": "cup", "partOfSpeech": "noun"}, ...]}
|
||||
|
||||
`entries` must contain exactly one object per input word.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROMPT_TEMPLATE = """\
|
||||
You are building a Spanish->English glossary for a language-learning app.
|
||||
|
||||
Input file: {input_path}
|
||||
Output file: {output_path}
|
||||
|
||||
Read the input file. It contains a JSON object with a `words` array; each item
|
||||
has a `word` (a lowercase Spanish word exactly as it appears in a book) and
|
||||
`examples` (sentences from the book that use that word).
|
||||
|
||||
For EACH word, produce one entry:
|
||||
- baseForm: the dictionary base form -- infinitive for verbs, masculine
|
||||
singular for nouns/adjectives, the word itself for invariant words.
|
||||
- english: a concise English translation (1-4 words). Use the sense the word
|
||||
carries in the example sentences. Many Spanish words are both a verb form
|
||||
AND a function word -- e.g. "como" is "I eat" (verb) and "as/like"
|
||||
(conjunction). Choose the meaning shown in the examples, not the most common
|
||||
dictionary sense.
|
||||
- partOfSpeech: one of verb, noun, adjective, adverb, pronoun, preposition,
|
||||
conjunction, article, interjection, numeral, proper noun, other.
|
||||
|
||||
Write the output file as JSON with this exact shape:
|
||||
{{"jobId": "<the jobId from the input>", "entries": [
|
||||
{{"word": "...", "baseForm": "...", "english": "...", "partOfSpeech": "..."}}
|
||||
]}}
|
||||
|
||||
`entries` MUST contain exactly one object per input word, cover every word, and
|
||||
echo each `word` back verbatim. Write nothing else to disk and produce no other
|
||||
output.
|
||||
"""
|
||||
|
||||
SENTENCE_SPLIT = re.compile(r"(?<=[.!?…])\s+")
|
||||
|
||||
|
||||
def is_punct(ch: str) -> bool:
|
||||
"""True for any Unicode punctuation — matches Swift's .punctuationCharacters."""
|
||||
return unicodedata.category(ch).startswith("P")
|
||||
|
||||
|
||||
def clean_word(token: str) -> str:
|
||||
"""Mirror BookReaderView.cleanWord: lowercase, strip leading/trailing
|
||||
punctuation, trim whitespace. Accents are preserved (no folding)."""
|
||||
t = token.lower()
|
||||
start, end = 0, len(t)
|
||||
while start < end and is_punct(t[start]):
|
||||
start += 1
|
||||
while end > start and is_punct(t[end - 1]):
|
||||
end -= 1
|
||||
return t[start:end].strip()
|
||||
|
||||
|
||||
def has_letter(s: str) -> bool:
|
||||
return any(c.isalpha() for c in s)
|
||||
|
||||
|
||||
def split_sentences(paragraph: str) -> list[str]:
|
||||
parts = SENTENCE_SPLIT.split(paragraph.strip())
|
||||
return [p.strip() for p in parts if p.strip()]
|
||||
|
||||
|
||||
def is_english_front_matter(chapter: dict, threshold: float = 0.5) -> bool:
|
||||
"""True when most of a chapter's paragraphs are untranslated — i.e. it is
|
||||
English front matter (Preface, reading guide, …) rather than Spanish story
|
||||
content. Story chapters still have *some* identical lines (verbatim
|
||||
`word = meaning` vocab entries), so a majority threshold separates them:
|
||||
front matter runs ~70-100% identical, stories ~25-35%. Only detectable once
|
||||
paragraphsEN is populated; raw extracted chapters carry none, so nothing is
|
||||
skipped on a fresh book's first pass."""
|
||||
es = [p.strip() for p in chapter.get("paragraphsES", [])]
|
||||
en = [p.strip() for p in chapter.get("paragraphsEN", [])]
|
||||
if not en or len(en) != len(es) or not es:
|
||||
return False
|
||||
identical = sum(1 for a, b in zip(es, en) if a == b)
|
||||
return identical / len(es) > threshold
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("slug")
|
||||
parser.add_argument("--batch-size", type=int, default=150)
|
||||
parser.add_argument("--max-examples", type=int, default=3)
|
||||
parser.add_argument("--build", type=Path, default=Path("build"))
|
||||
args = parser.parse_args()
|
||||
|
||||
base = args.build / args.slug
|
||||
chapters = json.loads((base / "chapters.json").read_text(encoding="utf-8"))
|
||||
gloss_dir = base / "glossary"
|
||||
gloss_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
examples: dict[str, list[str]] = {}
|
||||
first_seen: dict[str, int] = {}
|
||||
order = 0
|
||||
|
||||
skipped_front_matter = 0
|
||||
for ch in chapters["chapters"]:
|
||||
if is_english_front_matter(ch):
|
||||
skipped_front_matter += 1
|
||||
continue
|
||||
for paragraph in ch.get("paragraphsES", []):
|
||||
for sentence in split_sentences(paragraph):
|
||||
cleaned = {clean_word(tok) for tok in sentence.split()}
|
||||
for w in cleaned:
|
||||
if not w or not has_letter(w):
|
||||
continue
|
||||
if w not in first_seen:
|
||||
first_seen[w] = order
|
||||
order += 1
|
||||
examples[w] = []
|
||||
bucket = examples[w]
|
||||
if len(bucket) < args.max_examples and sentence not in bucket:
|
||||
bucket.append(sentence)
|
||||
|
||||
words = sorted(examples.keys(), key=lambda w: first_seen[w])
|
||||
|
||||
pending: list[str] = []
|
||||
completed: list[str] = []
|
||||
total_jobs = 0
|
||||
|
||||
for offset in range(0, len(words), args.batch_size):
|
||||
chunk = words[offset : offset + args.batch_size]
|
||||
job_id = f"gloss_b{offset // args.batch_size:02d}"
|
||||
input_path = gloss_dir / f"{job_id}.input.json"
|
||||
output_path = gloss_dir / f"{job_id}.output.json"
|
||||
input_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"jobId": job_id,
|
||||
"words": [{"word": w, "examples": examples[w]} for w in chunk],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
total_jobs += 1
|
||||
(completed if output_path.exists() else pending).append(job_id)
|
||||
|
||||
(gloss_dir / "_pending.txt").write_text(
|
||||
"\n".join(pending) + ("\n" if pending else ""), encoding="utf-8"
|
||||
)
|
||||
(gloss_dir / "_prompt_template.md").write_text(
|
||||
PROMPT_TEMPLATE.format(
|
||||
input_path="<JOB_INPUT_PATH>", output_path="<JOB_OUTPUT_PATH>"
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
print(f"Skipped front matter: {skipped_front_matter} chapter(s)")
|
||||
print(f"Distinct words: {len(words)}")
|
||||
print(f"Total glossary jobs: {total_jobs}")
|
||||
print(f" Completed: {len(completed)}")
|
||||
print(f" Pending: {len(pending)}")
|
||||
print(f"Manifest at: {gloss_dir / '_pending.txt'}")
|
||||
print(f"Prompt template at: {gloss_dir / '_prompt_template.md'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merge chapters.json + per-job translation outputs into the final bundled
|
||||
book_<slug>.json that the iOS app reads from its bundle.
|
||||
|
||||
Usage:
|
||||
python3 bundle_book.py <slug> [--build BUILD_DIR] [--dest DEST_DIR] [--require-all]
|
||||
|
||||
Inputs:
|
||||
BUILD_DIR/<slug>/chapters.json
|
||||
BUILD_DIR/<slug>/jobs/*.output.json (from translation subagents)
|
||||
BUILD_DIR/<slug>/glossary/*.output.json (from glossary subagents, Phase 2b)
|
||||
|
||||
Output:
|
||||
DEST_DIR/book_<slug>.json
|
||||
{
|
||||
"slug": "...",
|
||||
"title": "...",
|
||||
"author": "...",
|
||||
"language": "...",
|
||||
"chapters": [
|
||||
{"id": "ch1", "number": 1, "title": "Preface",
|
||||
"paragraphsES": ["...", ...],
|
||||
"paragraphsEN": ["...", ...]},
|
||||
...
|
||||
],
|
||||
"glossary": {
|
||||
"taza": {"baseForm": "taza", "english": "cup", "partOfSpeech": "noun"},
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
If --require-all is passed, the script fails if any translation OR glossary job
|
||||
is missing its output. Otherwise it fills missing translations with empty
|
||||
strings, leaves missing glossary entries out, and warns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_DEST = Path("../../Conjuga")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("slug")
|
||||
parser.add_argument("--build", type=Path, default=Path("build"))
|
||||
parser.add_argument("--dest", type=Path, default=None)
|
||||
parser.add_argument("--require-all", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
base = args.build / args.slug
|
||||
chapters = json.loads((base / "chapters.json").read_text(encoding="utf-8"))
|
||||
jobs_dir = base / "jobs"
|
||||
|
||||
# Index translation jobs by chapter -> ordered (offset, paragraphsEN).
|
||||
chapter_translations: dict[int, list[tuple[int, list[str]]]] = {}
|
||||
missing: list[str] = []
|
||||
|
||||
for input_path in sorted(jobs_dir.glob("*.input.json")):
|
||||
job_id = input_path.stem.removesuffix(".input")
|
||||
input_data = json.loads(input_path.read_text(encoding="utf-8"))
|
||||
output_path = jobs_dir / f"{job_id}.output.json"
|
||||
if not output_path.exists():
|
||||
missing.append(job_id)
|
||||
continue
|
||||
output_data = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
paragraphs_en = output_data.get("paragraphsEN", [])
|
||||
expected = len(input_data["paragraphsES"])
|
||||
if len(paragraphs_en) != expected:
|
||||
print(
|
||||
f"WARN: {job_id} length mismatch — got {len(paragraphs_en)}, "
|
||||
f"expected {expected}. Padding/truncating.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if len(paragraphs_en) < expected:
|
||||
paragraphs_en = paragraphs_en + [""] * (expected - len(paragraphs_en))
|
||||
else:
|
||||
paragraphs_en = paragraphs_en[:expected]
|
||||
chapter_translations.setdefault(input_data["chapter"], []).append(
|
||||
(input_data["rangeStart"], paragraphs_en)
|
||||
)
|
||||
|
||||
if missing:
|
||||
msg = f"{len(missing)} translation job(s) missing output: {missing[:5]}{'...' if len(missing) > 5 else ''}"
|
||||
if args.require_all:
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"WARN: {msg} — using empty strings for those paragraphs.", file=sys.stderr)
|
||||
|
||||
# Glossary (Phase 2b) — merge every glossary job's entries into one map
|
||||
# keyed by the cleaned word the app looks up.
|
||||
glossary_dir = base / "glossary"
|
||||
glossary: dict[str, dict] = {}
|
||||
glossary_missing: list[str] = []
|
||||
if glossary_dir.exists():
|
||||
for input_path in sorted(glossary_dir.glob("*.input.json")):
|
||||
job_id = input_path.stem.removesuffix(".input")
|
||||
output_path = glossary_dir / f"{job_id}.output.json"
|
||||
if not output_path.exists():
|
||||
glossary_missing.append(job_id)
|
||||
continue
|
||||
output_data = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
for entry in output_data.get("entries", []):
|
||||
word = (entry.get("word") or "").strip()
|
||||
if not word:
|
||||
continue
|
||||
glossary[word] = {
|
||||
"baseForm": entry.get("baseForm") or word,
|
||||
"english": entry.get("english") or "",
|
||||
"partOfSpeech": entry.get("partOfSpeech") or "",
|
||||
}
|
||||
if glossary_missing:
|
||||
msg = f"{len(glossary_missing)} glossary job(s) missing output: {glossary_missing[:5]}{'...' if len(glossary_missing) > 5 else ''}"
|
||||
if args.require_all:
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"WARN: {msg} — glossary will be incomplete.", file=sys.stderr)
|
||||
|
||||
bundled_chapters: list[dict] = []
|
||||
for ch in chapters["chapters"]:
|
||||
translations = sorted(chapter_translations.get(ch["number"], []))
|
||||
paragraphs_en: list[str] = []
|
||||
for _, en_chunk in translations:
|
||||
paragraphs_en.extend(en_chunk)
|
||||
# Pad to match ES length if jobs were missing for parts of this chapter.
|
||||
if len(paragraphs_en) < len(ch["paragraphsES"]):
|
||||
paragraphs_en += [""] * (len(ch["paragraphsES"]) - len(paragraphs_en))
|
||||
elif len(paragraphs_en) > len(ch["paragraphsES"]):
|
||||
paragraphs_en = paragraphs_en[: len(ch["paragraphsES"])]
|
||||
bundled_chapters.append(
|
||||
{
|
||||
"id": ch["id"],
|
||||
"number": ch["number"],
|
||||
"title": ch["title"],
|
||||
"paragraphsES": ch["paragraphsES"],
|
||||
"paragraphsEN": paragraphs_en,
|
||||
}
|
||||
)
|
||||
|
||||
payload = {
|
||||
"slug": chapters["slug"],
|
||||
"title": chapters["title"],
|
||||
"author": chapters["author"],
|
||||
"language": chapters["language"],
|
||||
"chapters": bundled_chapters,
|
||||
"glossary": glossary,
|
||||
}
|
||||
|
||||
dest_dir = (args.dest or DEFAULT_DEST).resolve()
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = dest_dir / f"book_{args.slug}.json"
|
||||
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"Wrote {out_path}")
|
||||
print(f" Chapters: {len(bundled_chapters)}")
|
||||
print(f" Translated jobs: {sum(len(v) for v in chapter_translations.values())} / {sum(len(v) for v in chapter_translations.values()) + len(missing)}")
|
||||
print(f" Glossary words: {len(glossary)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse an EPUB into chapters.json for the in-app Books feature.
|
||||
|
||||
Usage:
|
||||
python3 extract_epub.py <epub_path> [--slug SLUG] [--out OUT_DIR]
|
||||
|
||||
Defaults:
|
||||
SLUG derived from the EPUB filename (lowercased, dashed)
|
||||
OUT_DIR ./build/<slug>
|
||||
|
||||
Output:
|
||||
OUT_DIR/chapters.json
|
||||
{
|
||||
"title": "...",
|
||||
"author": "...",
|
||||
"language": "...",
|
||||
"slug": "...",
|
||||
"chapters": [
|
||||
{"id": "ch1", "number": 1, "title": "Preface",
|
||||
"paragraphsES": ["...", "..."]},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
How chapter grouping works:
|
||||
1. Read content.opf manifest (id -> href) and spine (ordered idrefs).
|
||||
2. Read toc.ncx navMap to get the ordered list of chapter (title, first-href).
|
||||
3. For each chapter, claim every spine file from its first href up to (but
|
||||
not including) the next chapter's first href.
|
||||
4. For each file in the chapter's range, parse <p> elements, strip tags,
|
||||
normalise whitespace + smart quotes, drop empties.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
import warnings
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from bs4 import BeautifulSoup, XMLParsedAsHTMLWarning
|
||||
|
||||
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
|
||||
|
||||
|
||||
NS = {
|
||||
"opf": "http://www.idpf.org/2007/opf",
|
||||
"dc": "http://purl.org/dc/elements/1.1/",
|
||||
"ncx": "http://www.daisy.org/z3986/2005/ncx/",
|
||||
"xhtml": "http://www.w3.org/1999/xhtml",
|
||||
}
|
||||
|
||||
|
||||
def _slugify(s: str) -> str:
|
||||
s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode("ascii")
|
||||
s = re.sub(r"[^a-zA-Z0-9]+", "-", s).strip("-").lower()
|
||||
return s or "book"
|
||||
|
||||
|
||||
def _normalise(text: str) -> str:
|
||||
# Collapse runs of whitespace, normalise smart quotes to plain ones.
|
||||
text = text.replace(" ", " ")
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
text = re.sub(r"\s+([.,;:!?…])", r"\1", text)
|
||||
text = re.sub(r"([¡¿])\s+", r"\1", text)
|
||||
return text
|
||||
|
||||
|
||||
def _read_zip_text(zf: zipfile.ZipFile, path: str) -> str:
|
||||
return zf.read(path).decode("utf-8")
|
||||
|
||||
|
||||
def _container_root(zf: zipfile.ZipFile) -> str:
|
||||
container = ET.fromstring(_read_zip_text(zf, "META-INF/container.xml"))
|
||||
rootfile = container.find(".//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile")
|
||||
if rootfile is None:
|
||||
raise RuntimeError("Missing rootfile entry in META-INF/container.xml")
|
||||
return rootfile.attrib["full-path"]
|
||||
|
||||
|
||||
def _parse_opf(zf: zipfile.ZipFile, opf_path: str):
|
||||
text = _read_zip_text(zf, opf_path)
|
||||
root = ET.fromstring(text)
|
||||
|
||||
title = (root.findtext(".//dc:title", default="", namespaces=NS) or "").strip()
|
||||
author = (root.findtext(".//dc:creator", default="", namespaces=NS) or "").strip()
|
||||
language = (root.findtext(".//dc:language", default="", namespaces=NS) or "").strip()
|
||||
|
||||
manifest: dict[str, str] = {}
|
||||
for item in root.findall("opf:manifest/opf:item", NS):
|
||||
manifest[item.attrib["id"]] = item.attrib["href"]
|
||||
|
||||
spine: list[str] = []
|
||||
for itemref in root.findall("opf:spine/opf:itemref", NS):
|
||||
spine.append(itemref.attrib["idref"])
|
||||
|
||||
ncx_id = root.find("opf:spine", NS).attrib.get("toc") if root.find("opf:spine", NS) is not None else None
|
||||
ncx_href = manifest.get(ncx_id) if ncx_id else None
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"author": author,
|
||||
"language": language,
|
||||
"manifest": manifest,
|
||||
"spine": spine,
|
||||
"ncx_href": ncx_href,
|
||||
"opf_dir": str(Path(opf_path).parent) if "/" in opf_path else "",
|
||||
}
|
||||
|
||||
|
||||
def _parse_ncx(zf: zipfile.ZipFile, ncx_path: str) -> list[dict]:
|
||||
text = _read_zip_text(zf, ncx_path)
|
||||
root = ET.fromstring(text)
|
||||
chapters: list[dict] = []
|
||||
for nav in root.findall("ncx:navMap/ncx:navPoint", NS):
|
||||
title = (nav.findtext("ncx:navLabel/ncx:text", default="", namespaces=NS) or "").strip()
|
||||
content = nav.find("ncx:content", NS)
|
||||
src = content.attrib.get("src", "") if content is not None else ""
|
||||
# Strip the anchor — we want the file path only.
|
||||
href = src.split("#", 1)[0]
|
||||
chapters.append({"title": title, "href": href})
|
||||
return chapters
|
||||
|
||||
|
||||
def _resolve_zip_path(base_dir: str, href: str) -> str:
|
||||
if not base_dir:
|
||||
return href
|
||||
return f"{base_dir}/{href}".lstrip("/")
|
||||
|
||||
|
||||
def _extract_paragraphs(zf: zipfile.ZipFile, zip_path: str) -> list[str]:
|
||||
try:
|
||||
html = _read_zip_text(zf, zip_path)
|
||||
except KeyError:
|
||||
return []
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
paragraphs: list[str] = []
|
||||
# Walk <p> and <li> in document order so vocab bullets (rendered as
|
||||
# <ul><li>...</li></ul> in this EPUB family) are kept alongside narrative
|
||||
# paragraphs. `<li>` rolls up its inline <b>/<span> children via get_text.
|
||||
for el in soup.find_all(["p", "li"]):
|
||||
text = _normalise(el.get_text(" ", strip=True))
|
||||
if not text:
|
||||
continue
|
||||
paragraphs.append(text)
|
||||
return paragraphs
|
||||
|
||||
|
||||
def _chapter_files(
|
||||
spine_files: list[str], chapter_hrefs: list[str]
|
||||
) -> list[list[str]]:
|
||||
"""Slice the spine into one list of files per chapter, using the chapter's
|
||||
first href as the chapter boundary. Files before the first chapter (e.g.
|
||||
cover, titlepage) are dropped."""
|
||||
boundaries: list[int] = []
|
||||
for href in chapter_hrefs:
|
||||
try:
|
||||
idx = spine_files.index(href)
|
||||
except ValueError:
|
||||
boundaries.append(-1)
|
||||
continue
|
||||
boundaries.append(idx)
|
||||
|
||||
ranges: list[list[str]] = []
|
||||
for i, start in enumerate(boundaries):
|
||||
if start < 0:
|
||||
ranges.append([])
|
||||
continue
|
||||
end = len(spine_files)
|
||||
for next_start in boundaries[i + 1:]:
|
||||
if next_start >= 0:
|
||||
end = next_start
|
||||
break
|
||||
ranges.append(spine_files[start:end])
|
||||
return ranges
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("epub", type=Path)
|
||||
parser.add_argument("--slug", default=None)
|
||||
parser.add_argument("--out", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.epub.exists():
|
||||
print(f"EPUB not found: {args.epub}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
with zipfile.ZipFile(args.epub) as zf:
|
||||
opf_path = _container_root(zf)
|
||||
opf = _parse_opf(zf, opf_path)
|
||||
|
||||
if not opf["ncx_href"]:
|
||||
print("No NCX found in spine; cannot derive chapter structure.", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
|
||||
ncx_path = _resolve_zip_path(opf["opf_dir"], opf["ncx_href"])
|
||||
toc = _parse_ncx(zf, ncx_path)
|
||||
|
||||
spine_files = [
|
||||
_resolve_zip_path(opf["opf_dir"], opf["manifest"].get(idref, ""))
|
||||
for idref in opf["spine"]
|
||||
]
|
||||
chapter_hrefs = [_resolve_zip_path(opf["opf_dir"], c["href"]) for c in toc]
|
||||
chapter_file_ranges = _chapter_files(spine_files, chapter_hrefs)
|
||||
|
||||
chapters_out: list[dict] = []
|
||||
for i, (meta, files) in enumerate(zip(toc, chapter_file_ranges), start=1):
|
||||
paragraphs: list[str] = []
|
||||
for f in files:
|
||||
paragraphs.extend(_extract_paragraphs(zf, f))
|
||||
# Drop leading paragraph(s) that just echo the chapter title — the
|
||||
# title is already stored separately.
|
||||
title_norm = _normalise(meta["title"]).lower()
|
||||
while paragraphs and _normalise(paragraphs[0]).lower() == title_norm:
|
||||
paragraphs.pop(0)
|
||||
chapters_out.append(
|
||||
{
|
||||
"id": f"ch{i}",
|
||||
"number": i,
|
||||
"title": meta["title"],
|
||||
"paragraphsES": paragraphs,
|
||||
}
|
||||
)
|
||||
|
||||
slug = args.slug or _slugify(opf["title"]) or args.epub.stem
|
||||
out_dir = args.out or (Path("build") / slug)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = out_dir / "chapters.json"
|
||||
|
||||
payload = {
|
||||
"title": opf["title"],
|
||||
"author": opf["author"],
|
||||
"language": opf["language"],
|
||||
"slug": slug,
|
||||
"chapters": chapters_out,
|
||||
}
|
||||
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
total_paragraphs = sum(len(c["paragraphsES"]) for c in chapters_out)
|
||||
print(f"Wrote {out_path}")
|
||||
print(f" Title: {opf['title']}")
|
||||
print(f" Author: {opf['author']}")
|
||||
print(f" Chapters: {len(chapters_out)}")
|
||||
print(f" Paragraphs: {total_paragraphs}")
|
||||
for ch in chapters_out:
|
||||
print(f" ch{ch['number']:02d} {len(ch['paragraphsES']):4d} ¶ {ch['title']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrate the books pipeline: EPUB -> chapters.json -> per-chapter job
|
||||
# manifest -> (translation by Claude Code subagents) -> bundled book_<slug>.json.
|
||||
#
|
||||
# This script DOES NOT run the LLM translation pass. After Phase 2 it stops
|
||||
# and prints how many jobs are pending. Use Claude Code subagents (or a fresh
|
||||
# session per the README) to fill in build/<slug>/jobs/*.output.json, then
|
||||
# re-run this script — it will pick up where it left off via Phase 3.
|
||||
#
|
||||
# Usage:
|
||||
# ./run.sh <epub_path> [--slug SLUG] [--batch-size N]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$HERE"
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "usage: $0 <epub_path> [--slug SLUG] [--batch-size N]"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
EPUB="$1"; shift
|
||||
SLUG=""
|
||||
BATCH_SIZE="30"
|
||||
GLOSSARY_BATCH_SIZE="150"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--slug) SLUG="$2"; shift 2 ;;
|
||||
--batch-size) BATCH_SIZE="$2"; shift 2 ;;
|
||||
--glossary-batch-size) GLOSSARY_BATCH_SIZE="$2"; shift 2 ;;
|
||||
*) echo "unknown option: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
EPUB_ABS="$(cd "$(dirname "$EPUB")" && pwd)/$(basename "$EPUB")"
|
||||
|
||||
echo "=== Phase 1: extract_epub.py ==="
|
||||
if [[ -n "$SLUG" ]]; then
|
||||
python3 extract_epub.py "$EPUB_ABS" --slug "$SLUG"
|
||||
else
|
||||
python3 extract_epub.py "$EPUB_ABS"
|
||||
fi
|
||||
|
||||
# If --slug wasn't passed, recover the slug from the chapters file just written.
|
||||
if [[ -z "$SLUG" ]]; then
|
||||
SLUG=$(python3 -c "import json,glob; p=sorted(glob.glob('build/*/chapters.json'), key=lambda x: -__import__('os').path.getmtime(x))[0]; print(json.load(open(p))['slug'])")
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== Phase 2: translate_chapters.py ==="
|
||||
python3 translate_chapters.py "$SLUG" --batch-size "$BATCH_SIZE"
|
||||
|
||||
PENDING_FILE="build/$SLUG/jobs/_pending.txt"
|
||||
PENDING_COUNT=$(wc -l < "$PENDING_FILE" | tr -d ' ')
|
||||
|
||||
echo
|
||||
echo "=== Phase 2b: build_glossary.py ==="
|
||||
python3 build_glossary.py "$SLUG" --batch-size "$GLOSSARY_BATCH_SIZE"
|
||||
|
||||
GLOSS_PENDING_FILE="build/$SLUG/glossary/_pending.txt"
|
||||
GLOSS_PENDING_COUNT=$(wc -l < "$GLOSS_PENDING_FILE" | tr -d ' ')
|
||||
TOTAL_PENDING=$((PENDING_COUNT + GLOSS_PENDING_COUNT))
|
||||
|
||||
echo
|
||||
echo "=== Phase 3: bundle_book.py ==="
|
||||
if [[ "$TOTAL_PENDING" -gt 0 ]]; then
|
||||
echo " $PENDING_COUNT translation job(s) and $GLOSS_PENDING_COUNT glossary job(s) still pending."
|
||||
echo " Run the Claude Code subagent step (see README.md) for BOTH manifests:"
|
||||
echo " build/$SLUG/jobs/_pending.txt (translation)"
|
||||
echo " build/$SLUG/glossary/_pending.txt (glossary)"
|
||||
echo " then re-run this script. Bundling with placeholders so you can preview now."
|
||||
python3 bundle_book.py "$SLUG"
|
||||
else
|
||||
python3 bundle_book.py "$SLUG" --require-all
|
||||
fi
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Split chapters.json into translation jobs that Claude Code subagents can
|
||||
process in parallel. Resumable: jobs whose output file already exists are
|
||||
skipped.
|
||||
|
||||
Usage:
|
||||
python3 translate_chapters.py <slug> [--batch-size N] [--build BUILD_DIR]
|
||||
|
||||
Inputs:
|
||||
BUILD_DIR/<slug>/chapters.json (from extract_epub.py)
|
||||
|
||||
Outputs:
|
||||
BUILD_DIR/<slug>/jobs/<jobid>.input.json (one per batch — read by subagents)
|
||||
BUILD_DIR/<slug>/jobs/_pending.txt (list of job IDs still missing output)
|
||||
BUILD_DIR/<slug>/jobs/_prompt_template.md (prompt the orchestrator hands each subagent)
|
||||
|
||||
Job layout (.input.json):
|
||||
{
|
||||
"jobId": "ch06_b00",
|
||||
"chapter": 6,
|
||||
"chapterTitle": "1. El Castillo",
|
||||
"rangeStart": 0,
|
||||
"rangeEnd": 30,
|
||||
"paragraphsES": ["...", "..."]
|
||||
}
|
||||
|
||||
Subagents must write `<jobid>.output.json` with shape:
|
||||
{"jobId": "ch06_b00", "paragraphsEN": ["...", "..."]}
|
||||
|
||||
The output array MUST have the same length as paragraphsES, in the same order.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROMPT_TEMPLATE = """\
|
||||
You are translating a chunk of a Spanish-language book into English for a
|
||||
language-learning app.
|
||||
|
||||
Input file: {input_path}
|
||||
Output file: {output_path}
|
||||
|
||||
Read the input file. It contains a JSON object with a `paragraphsES` array.
|
||||
Translate each paragraph into natural English. Preserve meaning, tone, and
|
||||
dialogue markers (—, –, ¡, ¿) as appropriate for the English output. Keep
|
||||
the same number of paragraphs in the same order.
|
||||
|
||||
Notes for translation quality:
|
||||
- This is a beginner Spanish reader, so prefer plain natural English over
|
||||
literary flourish.
|
||||
- Preserve proper nouns (character names, place names) verbatim.
|
||||
- Convert Spanish dialogue dashes (–, —) to English-style quotation marks
|
||||
ONLY if it reads more naturally; otherwise keep them as em-dashes.
|
||||
- Do NOT add explanatory parentheticals; the in-app dictionary handles
|
||||
per-word lookup.
|
||||
- Some paragraphs are vocabulary entries shaped like `palabra = meaning`
|
||||
(e.g. `alto = tall`, `el dueño = owner`). Keep these verbatim — both the
|
||||
Spanish word and its English gloss already coexist on the line, and the
|
||||
bilingual reader UI shows the same line in both views.
|
||||
|
||||
Write the output as JSON with shape:
|
||||
{{"jobId": "<the jobId from the input>", "paragraphsEN": [...]}}
|
||||
|
||||
The `paragraphsEN` array MUST be the same length and order as `paragraphsES`
|
||||
in the input. Write nothing else to disk and produce no other output.
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("slug")
|
||||
parser.add_argument("--batch-size", type=int, default=30)
|
||||
parser.add_argument("--build", type=Path, default=Path("build"))
|
||||
args = parser.parse_args()
|
||||
|
||||
base = args.build / args.slug
|
||||
chapters_path = base / "chapters.json"
|
||||
jobs_dir = base / "jobs"
|
||||
jobs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data = json.loads(chapters_path.read_text(encoding="utf-8"))
|
||||
|
||||
pending: list[str] = []
|
||||
completed: list[str] = []
|
||||
total_jobs = 0
|
||||
|
||||
for ch in data["chapters"]:
|
||||
paragraphs = ch["paragraphsES"]
|
||||
if not paragraphs:
|
||||
continue
|
||||
for offset in range(0, len(paragraphs), args.batch_size):
|
||||
chunk = paragraphs[offset : offset + args.batch_size]
|
||||
job_id = f"ch{ch['number']:02d}_b{offset // args.batch_size:02d}"
|
||||
input_path = jobs_dir / f"{job_id}.input.json"
|
||||
output_path = jobs_dir / f"{job_id}.output.json"
|
||||
|
||||
input_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"jobId": job_id,
|
||||
"chapter": ch["number"],
|
||||
"chapterTitle": ch["title"],
|
||||
"rangeStart": offset,
|
||||
"rangeEnd": offset + len(chunk),
|
||||
"paragraphsES": chunk,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
total_jobs += 1
|
||||
if output_path.exists():
|
||||
completed.append(job_id)
|
||||
else:
|
||||
pending.append(job_id)
|
||||
|
||||
(jobs_dir / "_pending.txt").write_text("\n".join(pending) + ("\n" if pending else ""))
|
||||
|
||||
(jobs_dir / "_prompt_template.md").write_text(
|
||||
PROMPT_TEMPLATE.format(
|
||||
input_path="<JOB_INPUT_PATH>",
|
||||
output_path="<JOB_OUTPUT_PATH>",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
print(f"Total translation jobs: {total_jobs}")
|
||||
print(f" Completed: {len(completed)}")
|
||||
print(f" Pending: {len(pending)}")
|
||||
print(f"Manifest at: {jobs_dir / '_pending.txt'}")
|
||||
print(f"Prompt template at: {jobs_dir / '_prompt_template.md'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -8486,7 +8486,7 @@
|
||||
"cards": [
|
||||
{
|
||||
"front": "tener",
|
||||
"back": "tengo",
|
||||
"back": "tengo — I have",
|
||||
"examples": [
|
||||
{
|
||||
"es": "The Spanish Verb \"Tener\"",
|
||||
@@ -8504,7 +8504,7 @@
|
||||
},
|
||||
{
|
||||
"front": "venir",
|
||||
"back": "vengo",
|
||||
"back": "vengo — I come",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Lo mejor está por venir.",
|
||||
@@ -8522,7 +8522,7 @@
|
||||
},
|
||||
{
|
||||
"front": "hacer",
|
||||
"back": "hago",
|
||||
"back": "hago — I do, I make",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Expressions with \"Hacer\"",
|
||||
@@ -8540,7 +8540,7 @@
|
||||
},
|
||||
{
|
||||
"front": "salir",
|
||||
"back": "salgo",
|
||||
"back": "salgo — I go out",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Usa el ascensor para salir.",
|
||||
@@ -8558,7 +8558,7 @@
|
||||
},
|
||||
{
|
||||
"front": "caer",
|
||||
"back": "caigo",
|
||||
"back": "caigo — I fall",
|
||||
"examples": [
|
||||
{
|
||||
"es": "¡Acabo de caer en que tengo una cita con el dentista en diez minutos!",
|
||||
@@ -8576,7 +8576,7 @@
|
||||
},
|
||||
{
|
||||
"front": "traer",
|
||||
"back": "traigo",
|
||||
"back": "traigo — I bring",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Esto puede traer cambios profundos en bares, discotecas y restaurantes.",
|
||||
@@ -8594,7 +8594,7 @@
|
||||
},
|
||||
{
|
||||
"front": "poner",
|
||||
"back": "pongo",
|
||||
"back": "pongo — I put",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Después de bañar a la bebé, hay que ponerle ropa limpia.",
|
||||
@@ -8612,7 +8612,7 @@
|
||||
},
|
||||
{
|
||||
"front": "decir",
|
||||
"back": "digo",
|
||||
"back": "digo — I say",
|
||||
"examples": [
|
||||
{
|
||||
"es": "¿Jura decir la verdad?",
|
||||
@@ -8630,7 +8630,7 @@
|
||||
},
|
||||
{
|
||||
"front": "conducir",
|
||||
"back": "conduzco",
|
||||
"back": "conduzco — I lead, I drive",
|
||||
"examples": [
|
||||
{
|
||||
"es": "conducir(kohn-doo-seer)",
|
||||
@@ -8648,7 +8648,7 @@
|
||||
},
|
||||
{
|
||||
"front": "conocer",
|
||||
"back": "conozco",
|
||||
"back": "conozco — I know, I meet",
|
||||
"examples": [
|
||||
{
|
||||
"es": "conocer(koh-noh-sehr)",
|
||||
@@ -8666,7 +8666,7 @@
|
||||
},
|
||||
{
|
||||
"front": "agradecer",
|
||||
"back": "agradezco",
|
||||
"back": "agradezco — I thank",
|
||||
"examples": [
|
||||
{
|
||||
"es": "agradecer(ah-grah-deh-sehr)",
|
||||
@@ -8684,7 +8684,7 @@
|
||||
},
|
||||
{
|
||||
"front": "parecer",
|
||||
"back": "parezco",
|
||||
"back": "parezco — I seem",
|
||||
"examples": [
|
||||
{
|
||||
"es": "parecer(pah-reh-sehr)",
|
||||
@@ -8702,7 +8702,7 @@
|
||||
},
|
||||
{
|
||||
"front": "crecer",
|
||||
"back": "crezco",
|
||||
"back": "crezco — I grow",
|
||||
"examples": [
|
||||
{
|
||||
"es": "crecer(kreh-sehr)",
|
||||
@@ -8720,7 +8720,7 @@
|
||||
},
|
||||
{
|
||||
"front": "producir",
|
||||
"back": "produzco",
|
||||
"back": "produzco — I produce",
|
||||
"examples": [
|
||||
{
|
||||
"es": "producir(proh-doo-seer)",
|
||||
@@ -8738,7 +8738,7 @@
|
||||
},
|
||||
{
|
||||
"front": "traducir",
|
||||
"back": "traduzco",
|
||||
"back": "traduzco — I translate",
|
||||
"examples": [
|
||||
{
|
||||
"es": "traducir(trah-doo-seer)",
|
||||
@@ -8756,7 +8756,7 @@
|
||||
},
|
||||
{
|
||||
"front": "establecer",
|
||||
"back": "establezco",
|
||||
"back": "establezco — I establish",
|
||||
"examples": [
|
||||
{
|
||||
"es": "establecer(ehs-tah-bleh-sehr)",
|
||||
@@ -8774,7 +8774,7 @@
|
||||
},
|
||||
{
|
||||
"front": "elejir",
|
||||
"back": "elijo",
|
||||
"back": "elijo — I choose",
|
||||
"examples": [
|
||||
{
|
||||
"es": "En realidad cada persona será libre de elejir su comida.",
|
||||
@@ -8792,7 +8792,7 @@
|
||||
},
|
||||
{
|
||||
"front": "proteger",
|
||||
"back": "protejo",
|
||||
"back": "protejo — I protect",
|
||||
"examples": [
|
||||
{
|
||||
"es": "proteger(proh-teh-hehr)",
|
||||
@@ -8810,7 +8810,7 @@
|
||||
},
|
||||
{
|
||||
"front": "dirigir",
|
||||
"back": "dirijo",
|
||||
"back": "dirijo — I manage, I direct",
|
||||
"examples": [
|
||||
{
|
||||
"es": "dirigir(dee-ree-heer)",
|
||||
@@ -8828,7 +8828,7 @@
|
||||
},
|
||||
{
|
||||
"front": "fingir",
|
||||
"back": "finjo",
|
||||
"back": "finjo — I pretend, I feign",
|
||||
"examples": [
|
||||
{
|
||||
"es": "fingir(feen-heer)",
|
||||
@@ -8846,7 +8846,7 @@
|
||||
},
|
||||
{
|
||||
"front": "sumergir",
|
||||
"back": "sumerjo",
|
||||
"back": "sumerjo — I submerge",
|
||||
"examples": [
|
||||
{
|
||||
"es": "sumergir(soo-mehr-heer)",
|
||||
@@ -8864,7 +8864,7 @@
|
||||
},
|
||||
{
|
||||
"front": "ver",
|
||||
"back": "veo",
|
||||
"back": "veo — I see",
|
||||
"examples": [
|
||||
{
|
||||
"es": "¿Quieres ver mi carro nuevo?",
|
||||
@@ -8882,7 +8882,7 @@
|
||||
},
|
||||
{
|
||||
"front": "saber",
|
||||
"back": "sé",
|
||||
"back": "sé — I know, I taste",
|
||||
"examples": [
|
||||
{
|
||||
"es": "El saber popular se basa en creencias.",
|
||||
@@ -8900,7 +8900,7 @@
|
||||
},
|
||||
{
|
||||
"front": "distinguir",
|
||||
"back": "distingo",
|
||||
"back": "distingo — I distinguish",
|
||||
"examples": [
|
||||
{
|
||||
"es": "distinguir(dees-teeng-geer)",
|
||||
@@ -8918,7 +8918,7 @@
|
||||
},
|
||||
{
|
||||
"front": "oír",
|
||||
"back": "oigo",
|
||||
"back": "oigo — I hear",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Me quejé a mucha gente, pero nadie quiso oírme.",
|
||||
@@ -8943,7 +8943,7 @@
|
||||
"cards": [
|
||||
{
|
||||
"front": "tener",
|
||||
"back": "tengo",
|
||||
"back": "tengo — I have",
|
||||
"examples": [
|
||||
{
|
||||
"es": "The Spanish Verb \"Tener\"",
|
||||
@@ -8961,7 +8961,7 @@
|
||||
},
|
||||
{
|
||||
"front": "venir",
|
||||
"back": "vengo",
|
||||
"back": "vengo — I come",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Lo mejor está por venir.",
|
||||
@@ -8979,7 +8979,7 @@
|
||||
},
|
||||
{
|
||||
"front": "hacer",
|
||||
"back": "hago",
|
||||
"back": "hago — I do, I make",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Expressions with \"Hacer\"",
|
||||
@@ -8997,7 +8997,7 @@
|
||||
},
|
||||
{
|
||||
"front": "salir",
|
||||
"back": "salgo",
|
||||
"back": "salgo — I go out",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Usa el ascensor para salir.",
|
||||
@@ -9015,7 +9015,7 @@
|
||||
},
|
||||
{
|
||||
"front": "caer",
|
||||
"back": "caigo",
|
||||
"back": "caigo — I fall",
|
||||
"examples": [
|
||||
{
|
||||
"es": "¡Acabo de caer en que tengo una cita con el dentista en diez minutos!",
|
||||
@@ -9033,7 +9033,7 @@
|
||||
},
|
||||
{
|
||||
"front": "traer",
|
||||
"back": "traigo",
|
||||
"back": "traigo — I bring",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Esto puede traer cambios profundos en bares, discotecas y restaurantes.",
|
||||
@@ -9051,7 +9051,7 @@
|
||||
},
|
||||
{
|
||||
"front": "poner",
|
||||
"back": "pongo",
|
||||
"back": "pongo — I put",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Después de bañar a la bebé, hay que ponerle ropa limpia.",
|
||||
@@ -9069,7 +9069,7 @@
|
||||
},
|
||||
{
|
||||
"front": "decir",
|
||||
"back": "digo",
|
||||
"back": "digo — I say",
|
||||
"examples": [
|
||||
{
|
||||
"es": "¿Jura decir la verdad?",
|
||||
@@ -9087,7 +9087,7 @@
|
||||
},
|
||||
{
|
||||
"front": "conducir",
|
||||
"back": "conduzco",
|
||||
"back": "conduzco — I lead, I drive",
|
||||
"examples": [
|
||||
{
|
||||
"es": "conducir(kohn-doo-seer)",
|
||||
@@ -9105,7 +9105,7 @@
|
||||
},
|
||||
{
|
||||
"front": "conocer",
|
||||
"back": "conozco",
|
||||
"back": "conozco — I know, I meet",
|
||||
"examples": [
|
||||
{
|
||||
"es": "conocer(koh-noh-sehr)",
|
||||
@@ -9123,7 +9123,7 @@
|
||||
},
|
||||
{
|
||||
"front": "agradecer",
|
||||
"back": "agradezco",
|
||||
"back": "agradezco — I thank",
|
||||
"examples": [
|
||||
{
|
||||
"es": "agradecer(ah-grah-deh-sehr)",
|
||||
@@ -9141,7 +9141,7 @@
|
||||
},
|
||||
{
|
||||
"front": "parecer",
|
||||
"back": "parezco",
|
||||
"back": "parezco — I seem",
|
||||
"examples": [
|
||||
{
|
||||
"es": "parecer(pah-reh-sehr)",
|
||||
@@ -9159,7 +9159,7 @@
|
||||
},
|
||||
{
|
||||
"front": "crecer",
|
||||
"back": "crezco",
|
||||
"back": "crezco — I grow",
|
||||
"examples": [
|
||||
{
|
||||
"es": "crecer(kreh-sehr)",
|
||||
@@ -9177,7 +9177,7 @@
|
||||
},
|
||||
{
|
||||
"front": "producir",
|
||||
"back": "produzco",
|
||||
"back": "produzco — I produce",
|
||||
"examples": [
|
||||
{
|
||||
"es": "producir(proh-doo-seer)",
|
||||
@@ -9195,7 +9195,7 @@
|
||||
},
|
||||
{
|
||||
"front": "traducir",
|
||||
"back": "traduzco",
|
||||
"back": "traduzco — I translate",
|
||||
"examples": [
|
||||
{
|
||||
"es": "traducir(trah-doo-seer)",
|
||||
@@ -9213,7 +9213,7 @@
|
||||
},
|
||||
{
|
||||
"front": "establecer",
|
||||
"back": "establezco",
|
||||
"back": "establezco — I establish",
|
||||
"examples": [
|
||||
{
|
||||
"es": "establecer(ehs-tah-bleh-sehr)",
|
||||
@@ -9231,7 +9231,7 @@
|
||||
},
|
||||
{
|
||||
"front": "elejir",
|
||||
"back": "elijo",
|
||||
"back": "elijo — I choose",
|
||||
"examples": [
|
||||
{
|
||||
"es": "En realidad cada persona será libre de elejir su comida.",
|
||||
@@ -9249,7 +9249,7 @@
|
||||
},
|
||||
{
|
||||
"front": "proteger",
|
||||
"back": "protejo",
|
||||
"back": "protejo — I protect",
|
||||
"examples": [
|
||||
{
|
||||
"es": "proteger(proh-teh-hehr)",
|
||||
@@ -9267,7 +9267,7 @@
|
||||
},
|
||||
{
|
||||
"front": "dirigir",
|
||||
"back": "dirijo",
|
||||
"back": "dirijo — I manage, I direct",
|
||||
"examples": [
|
||||
{
|
||||
"es": "dirigir(dee-ree-heer)",
|
||||
@@ -9285,7 +9285,7 @@
|
||||
},
|
||||
{
|
||||
"front": "fingir",
|
||||
"back": "finjo",
|
||||
"back": "finjo — I pretend, I feign",
|
||||
"examples": [
|
||||
{
|
||||
"es": "fingir(feen-heer)",
|
||||
@@ -9303,7 +9303,7 @@
|
||||
},
|
||||
{
|
||||
"front": "sumergir",
|
||||
"back": "sumerjo",
|
||||
"back": "sumerjo — I submerge",
|
||||
"examples": [
|
||||
{
|
||||
"es": "sumergir(soo-mehr-heer)",
|
||||
@@ -9321,7 +9321,7 @@
|
||||
},
|
||||
{
|
||||
"front": "ver",
|
||||
"back": "veo",
|
||||
"back": "veo — I see",
|
||||
"examples": [
|
||||
{
|
||||
"es": "¿Quieres ver mi carro nuevo?",
|
||||
@@ -9339,7 +9339,7 @@
|
||||
},
|
||||
{
|
||||
"front": "saber",
|
||||
"back": "sé",
|
||||
"back": "sé — I know, I taste",
|
||||
"examples": [
|
||||
{
|
||||
"es": "El saber popular se basa en creencias.",
|
||||
@@ -9357,7 +9357,7 @@
|
||||
},
|
||||
{
|
||||
"front": "distinguir",
|
||||
"back": "distingo",
|
||||
"back": "distingo — I distinguish",
|
||||
"examples": [
|
||||
{
|
||||
"es": "distinguir(dees-teeng-geer)",
|
||||
@@ -9375,7 +9375,7 @@
|
||||
},
|
||||
{
|
||||
"front": "oír",
|
||||
"back": "oigo",
|
||||
"back": "oigo — I hear",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Me quejé a mucha gente, pero nadie quiso oírme.",
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a markdown report of every curated YouTube video referenced by the app.
|
||||
|
||||
Reads Conjuga/youtube_videos.json, queries yt-dlp for metadata on each video,
|
||||
and emits Conjuga/youtube_videos.md with tables for tense guides and grammar
|
||||
notes plus a list of topics with no curated video.
|
||||
|
||||
Usage:
|
||||
python3 Scripts/generate_videos_markdown.py
|
||||
|
||||
Requires `yt-dlp` on PATH. Videos that have been taken down or made private
|
||||
appear in the tables with an "(unavailable)" marker in the title column.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
VIDEOS_JSON = REPO_ROOT / "Conjuga" / "youtube_videos.json"
|
||||
OUTPUT_MD = REPO_ROOT / "Conjuga" / "youtube_videos.md"
|
||||
|
||||
# The curated ids we expect — anything in the source file that's missing from
|
||||
# the JSON shows up in the "missing" section at the bottom.
|
||||
EXPECTED_TENSE_IDS = [
|
||||
"ind_presente", "ind_preterito", "ind_imperfecto", "ind_futuro",
|
||||
"ind_perfecto", "ind_pluscuamperfecto", "ind_futuro_perfecto",
|
||||
"ind_preterito_anterior",
|
||||
"cond_presente", "cond_perfecto",
|
||||
"subj_presente", "subj_imperfecto_1", "subj_imperfecto_2",
|
||||
"subj_perfecto", "subj_pluscuamperfecto_1", "subj_pluscuamperfecto_2",
|
||||
"subj_futuro", "subj_futuro_perfecto",
|
||||
"imp_afirmativo", "imp_negativo",
|
||||
]
|
||||
|
||||
EXPECTED_GRAMMAR_IDS = [
|
||||
"ser-vs-estar", "por-vs-para", "preterite-vs-imperfect",
|
||||
"subjunctive-triggers", "reflexive-verbs", "object-pronouns",
|
||||
"gustar-like-verbs", "comparatives-superlatives",
|
||||
"conditional-if-clauses", "commands-imperative", "saber-vs-conocer",
|
||||
"double-negatives", "adjective-placement", "tener-expressions",
|
||||
"personal-a", "relative-pronouns", "future-vs-ir-a",
|
||||
"accent-marks-stress", "se-constructions", "estar-gerund-progressive",
|
||||
"spanish-suffixes", "common-irregular-verbs", "types-of-irregular-verbs",
|
||||
"present-indicative-conjugation", "articles-and-gender",
|
||||
"possessive-adjectives", "demonstrative-adjectives",
|
||||
"greetings-farewells", "poder-infinitive", "al-del-contractions",
|
||||
"prepositional-pronouns", "irregular-yo-verbs", "stem-changing-verbs",
|
||||
"stressed-possessives", "present-perfect-tense", "future-perfect-tense",
|
||||
]
|
||||
|
||||
|
||||
def fetch_metadata(video_id: str) -> dict:
|
||||
"""Return a dict of useful metadata fields for a single video.
|
||||
|
||||
On any yt-dlp failure (video removed, network issue, extraction break)
|
||||
returns a dict with `unavailable=True` so the caller can mark the row.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["yt-dlp", "--skip-download", "--dump-json", "--no-warnings", "--", video_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"unavailable": True, "reason": "timeout"}
|
||||
|
||||
if result.returncode != 0:
|
||||
# yt-dlp errors look like:
|
||||
# "ERROR: [youtube] ID: <reason>. <cookie/help nag with URLs…>"
|
||||
# Extract just <reason> and drop everything after the first "." so the
|
||||
# markdown table stays readable. Help URLs contain colons so a naive
|
||||
# split-on-colon grabs the wrong chunk.
|
||||
reason = "yt-dlp failed"
|
||||
pattern = re.compile(r"ERROR:\s*\[[^\]]+\]\s*[^:]+:\s*(.+)")
|
||||
for line in result.stderr.strip().splitlines():
|
||||
m = pattern.search(line)
|
||||
if m:
|
||||
reason = m.group(1).split(". ")[0].rstrip(".")
|
||||
break
|
||||
return {"unavailable": True, "reason": reason}
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return {"unavailable": True, "reason": "invalid json"}
|
||||
|
||||
return {
|
||||
"unavailable": False,
|
||||
"title": data.get("title") or "",
|
||||
"uploader": data.get("uploader") or data.get("channel") or "",
|
||||
"upload_date": data.get("upload_date") or "", # YYYYMMDD
|
||||
"duration": data.get("duration"), # seconds
|
||||
"view_count": data.get("view_count"),
|
||||
"like_count": data.get("like_count"),
|
||||
}
|
||||
|
||||
|
||||
def fmt_duration(seconds: int | None) -> str:
|
||||
if not seconds:
|
||||
return "—"
|
||||
h, rem = divmod(int(seconds), 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
if h:
|
||||
return f"{h}:{m:02d}:{s:02d}"
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
|
||||
def fmt_date(raw: str) -> str:
|
||||
if not raw or len(raw) != 8:
|
||||
return "—"
|
||||
return f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]}"
|
||||
|
||||
|
||||
def fmt_int(n: int | None) -> str:
|
||||
if n is None:
|
||||
return "—"
|
||||
return f"{n:,}"
|
||||
|
||||
|
||||
def render_row(key: str, curated: dict, meta: dict) -> str:
|
||||
video_id = curated["videoId"]
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
if meta.get("unavailable"):
|
||||
title = f"_(unavailable — {meta.get('reason', 'unknown')})_"
|
||||
channel = "—"
|
||||
uploaded = "—"
|
||||
duration = "—"
|
||||
views = "—"
|
||||
likes = "—"
|
||||
else:
|
||||
title = meta.get("title") or curated.get("title") or ""
|
||||
# Escape pipes in titles so table rendering doesn't break.
|
||||
title = title.replace("|", "\\|")
|
||||
channel = (meta.get("uploader") or "—").replace("|", "\\|")
|
||||
uploaded = fmt_date(meta.get("upload_date", ""))
|
||||
duration = fmt_duration(meta.get("duration"))
|
||||
views = fmt_int(meta.get("view_count"))
|
||||
likes = fmt_int(meta.get("like_count"))
|
||||
|
||||
return f"| `{key}` | {title} | {channel} | {uploaded} | {duration} | {views} | {likes} | [watch]({url}) |"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with VIDEOS_JSON.open() as f:
|
||||
data = json.load(f)
|
||||
|
||||
tense_entries = data.get("tenseGuides", {})
|
||||
grammar_entries = data.get("grammarNotes", {})
|
||||
|
||||
# Collect all unique videoIds so we only call yt-dlp once per video
|
||||
# (several grammar notes reuse tense-guide videos).
|
||||
video_ids = {e["videoId"] for e in tense_entries.values()} | {
|
||||
e["videoId"] for e in grammar_entries.values()
|
||||
}
|
||||
|
||||
print(f"Fetching metadata for {len(video_ids)} unique videos…", file=sys.stderr)
|
||||
|
||||
metadata: dict[str, dict] = {}
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
future_to_id = {pool.submit(fetch_metadata, vid): vid for vid in video_ids}
|
||||
for future in as_completed(future_to_id):
|
||||
vid = future_to_id[future]
|
||||
metadata[vid] = future.result()
|
||||
status = "✗" if metadata[vid].get("unavailable") else "✓"
|
||||
print(f" {status} {vid}", file=sys.stderr)
|
||||
|
||||
missing_tenses = [tid for tid in EXPECTED_TENSE_IDS if tid not in tense_entries]
|
||||
missing_grammar = [gid for gid in EXPECTED_GRAMMAR_IDS if gid not in grammar_entries]
|
||||
|
||||
today = date.today().isoformat()
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("# Curated YouTube Videos")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Every tense guide and grammar note in the app can be tied to a single "
|
||||
"curated YouTube video. This file is generated from "
|
||||
"`Conjuga/youtube_videos.json` by `Scripts/generate_videos_markdown.py` "
|
||||
"— regenerate when you add or change entries."
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f"- Total tense-guide entries: **{len(tense_entries)}** of {len(EXPECTED_TENSE_IDS)}")
|
||||
lines.append(f"- Total grammar-note entries: **{len(grammar_entries)}** of {len(EXPECTED_GRAMMAR_IDS)}")
|
||||
lines.append(f"- Last verified: **{today}** (run `python3 Scripts/generate_videos_markdown.py` to refresh)")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Like counts are often blank because YouTube hides the public count on "
|
||||
"most videos for signed-out requests. Titles and durations are pulled "
|
||||
"live from YouTube; unavailable entries mean the video has been taken "
|
||||
"down, made private, or region-locked. A few entries marked "
|
||||
"\"not available on this app\" are a transient yt-dlp extraction limit "
|
||||
"— the video itself still plays fine when tapping Stream in the app."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Tense guides section
|
||||
lines.append("## Tense guides")
|
||||
lines.append("")
|
||||
lines.append("Tied to `TenseGuide.tenseId` in the Guide tab.")
|
||||
lines.append("")
|
||||
lines.append("| Tense ID | Title | Channel | Uploaded | Duration | Views | Likes | URL |")
|
||||
lines.append("|---|---|---|---|---|---|---|---|")
|
||||
for tid in EXPECTED_TENSE_IDS:
|
||||
if tid not in tense_entries:
|
||||
continue
|
||||
entry = tense_entries[tid]
|
||||
lines.append(render_row(tid, entry, metadata.get(entry["videoId"], {})))
|
||||
lines.append("")
|
||||
|
||||
# Grammar notes section
|
||||
lines.append("## Grammar notes")
|
||||
lines.append("")
|
||||
lines.append("Tied to `GrammarNote.id` (hand-authored + generated) in the Guide → Grammar tab.")
|
||||
lines.append("")
|
||||
lines.append("| Grammar ID | Title | Channel | Uploaded | Duration | Views | Likes | URL |")
|
||||
lines.append("|---|---|---|---|---|---|---|---|")
|
||||
for gid in EXPECTED_GRAMMAR_IDS:
|
||||
if gid not in grammar_entries:
|
||||
continue
|
||||
entry = grammar_entries[gid]
|
||||
lines.append(render_row(gid, entry, metadata.get(entry["videoId"], {})))
|
||||
lines.append("")
|
||||
|
||||
# Missing section
|
||||
if missing_tenses or missing_grammar:
|
||||
lines.append("## Topics without a curated video")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"These show a \"No video yet\" label in the app. Add entries to "
|
||||
"`Conjuga/youtube_videos.json` to fill them in."
|
||||
)
|
||||
lines.append("")
|
||||
if missing_tenses:
|
||||
lines.append("**Tense guides:**")
|
||||
lines.append("")
|
||||
for tid in missing_tenses:
|
||||
lines.append(f"- `{tid}`")
|
||||
lines.append("")
|
||||
if missing_grammar:
|
||||
lines.append("**Grammar notes:**")
|
||||
lines.append("")
|
||||
for gid in missing_grammar:
|
||||
lines.append(f"- `{gid}`")
|
||||
lines.append("")
|
||||
|
||||
OUTPUT_MD.write_text("\n".join(lines))
|
||||
print(f"\nWrote {OUTPUT_MD.relative_to(REPO_ROOT)}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,2 @@
|
||||
in/
|
||||
out/
|
||||
@@ -0,0 +1,119 @@
|
||||
# Guide enrichment plan
|
||||
|
||||
**Trigger**: WEIRDO was missing from the present-subjunctive guide. That's a perfect example of a deeper problem — most tense guides are surface-level reference cards (2-3 usages + examples), missing the mnemonics, contrast tables, and exception lists a real Spanish teacher would hand out.
|
||||
|
||||
**Goal**: bring every tense guide and grammar note up to "teacher-handout" depth — enough that a learner could study from it alone and pass a quiz.
|
||||
|
||||
## Current state (audit, 2026-05-11)
|
||||
|
||||
| Surface | Items | Source of truth | Typical body length | Verdict |
|
||||
|---|---|---|---|---|
|
||||
| Tense guides | 20 | `Conjuga/Conjuga/conjuga_data.json` → `tenseGuides[]` | 500–1500 chars | **Shallow** — bare *Usages* + examples |
|
||||
| Grammar notes | ~36 | `Conjuga/Conjuga/Models/GrammarNote.swift` (`GrammarNote.allNotes`, `generatedNotes`) | 1500–3000 chars | **Decent** — most have mnemonics and contrast examples |
|
||||
| Reference store | — | `Conjuga/Conjuga/Services/ReferenceStore.swift` | varies | Not in scope for this pass |
|
||||
|
||||
Tense guides are the bulk of the work. Grammar notes need a smaller audit-and-fill pass.
|
||||
|
||||
## What "thorough" looks like
|
||||
|
||||
Every tense guide should include, at minimum:
|
||||
|
||||
1. **Quick TL;DR** — one sentence: what is this tense for?
|
||||
2. **When to use it** — numbered usages, each with 2 contrast examples (a clear case and a borderline / common-mistake case).
|
||||
3. **How to form it** — conjugation pattern for regular verbs (one table per AR/ER/IR if it differs), plus the irregular pattern callout if applicable. Cross-reference the conjugator screens if relevant.
|
||||
4. **Common irregulars** — top 5–10 irregular verbs that learners will hit immediately in this tense (ser, estar, ir, tener, haber, dar, ver, decir, hacer, querer, poder, poner, saber, salir, traer, venir).
|
||||
5. **Triggers / mnemonics** — words and structures that signal this tense. WEIRDO and ESCAPA for subjunctive; "yesterday / last X / specific time" for preterite; "used to / when I was a kid" for imperfect; etc.
|
||||
6. **Pitfalls** — the top 3–5 mistakes English speakers make. e.g. preterite vs imperfect mixups, ir vs venir, ser vs estar overlap.
|
||||
7. **Tense-vs-tense contrast** — pair with the closest neighbour and show 2 minimal pairs (preterite ↔ imperfect, present ↔ present-progressive, future ↔ ir-a + infinitive, subjunctive-presente ↔ subjunctive-imperfecto).
|
||||
8. **Real-world feel** — 2–3 dialogue-style examples showing the tense in natural use, not just isolated sentences.
|
||||
|
||||
Every grammar note should include, at minimum:
|
||||
1. The core distinction in one line.
|
||||
2. Each side of the distinction with 4–6 clear examples covering different positions in a sentence.
|
||||
3. A mnemonic if one is standard in the language (DOCTOR/PLACE, WEIRDO, ESCAPA, etc.).
|
||||
4. Edge cases / verbs that change meaning (e.g. ser/estar adjectives, conocer/saber overlap).
|
||||
5. A practice prompt: "Try translating these 3 sentences, then check below."
|
||||
|
||||
## Priority order
|
||||
|
||||
Triaged by learner impact (frequency of use × typical confusion):
|
||||
|
||||
**Tier 1 — most-used, most-confused** (do first):
|
||||
1. `ind_presente` (Present indicative) — already 1324 chars, the longest tense guide. Audit for gaps; probably needs irregular tables.
|
||||
2. `ind_preterito` (Preterite) — currently 492 chars, the shortest. **Highest priority** — every learner hits this and gets it wrong.
|
||||
3. `ind_imperfecto` (Imperfect) — 774 chars. Always taught alongside preterite; the contrast is the entire game.
|
||||
4. `subj_presente` (Present subjunctive) — ✅ done in this pass.
|
||||
5. `imp_afirmativo` + `imp_negativo` (Imperative pair) — combined 2037 chars. Needs the tú/usted/nosotros/vosotros table and the negative-flips-to-subjunctive rule highlighted.
|
||||
|
||||
**Tier 2 — common but often skimped**:
|
||||
6. `ind_futuro` (Simple future) — needs contrast with ir-a + infinitive (already covered in grammar notes; cross-link).
|
||||
7. `cond_presente` (Conditional) — needs the "if-clause" patterns and the "softening request" usage ("¿Podrías…?").
|
||||
8. `ind_perfecto` (Present perfect) — needs the haber + past participle conjugation table and the "ya / todavía / alguna vez" trigger words.
|
||||
9. `subj_imperfecto_1` + `subj_imperfecto_2` (Past subjunctive -ra / -se) — needs the if-clause + condicional pairing.
|
||||
|
||||
**Tier 3 — compound and less-frequent** (still must be thorough):
|
||||
10. `ind_pluscuamperfecto`, `ind_futuro_perfecto`, `ind_preterito_anterior` (literary)
|
||||
11. `cond_perfecto`, `subj_perfecto`, `subj_pluscuamperfecto_1`, `subj_pluscuamperfecto_2`
|
||||
12. `subj_futuro`, `subj_futuro_perfecto` (largely archaic — note they're rare but explain why they exist)
|
||||
|
||||
**Grammar notes audit**:
|
||||
- Pass through all 36, score each on the "thorough" criteria above.
|
||||
- Fill the gaps. Most already have mnemonics; some don't.
|
||||
|
||||
## Research sources
|
||||
|
||||
Cite explicitly in each draft so reviewers can verify. Order of trust:
|
||||
|
||||
1. **Real Academia Española (RAE) — Nueva gramática de la lengua española** — authoritative reference. Free online: `rae.es`.
|
||||
2. **Studyspanish.com** and **SpanishDict.com** grammar references — best free per-topic explanations, well-curated example sentences.
|
||||
3. **Practice Makes Perfect: Complete Spanish Grammar** (Dorothy Richmond, McGraw-Hill) — standard teaching reference. The PDF is already at the repo root for cross-reference.
|
||||
4. **Lawless Spanish** (Laura Lawless) — accurate, concise, good on subjunctive nuances.
|
||||
5. **The user's existing textbook** — *Complete Spanish Step-by-Step* (Bregstein) is already bundled. Cross-reference its chapter on each tense to keep voice consistent.
|
||||
6. **YouTube — Butterfly Spanish (Ana), Spring Spanish, Dreaming Spanish (Pablo)** — for natural-use examples and the "feel" of when a native reaches for the tense. The repo already has a curated YouTube list at `Conjuga/Conjuga/youtube_videos.json` — pull from there when a topic has a matching video.
|
||||
|
||||
For mnemonics specifically: WEIRDO, ESCAPA, DOCTOR, PLACE are standard. Don't invent new ones unless we can't find a known one.
|
||||
|
||||
## Workflow per topic
|
||||
|
||||
This is what an enrichment "unit of work" looks like:
|
||||
|
||||
1. **Draft** — A research agent (Claude Code subagent, no API key, same pattern as the book translation pipeline) reads the current guide body, consults the sources listed above, drafts a new body following the "thorough" structure. Writes to `Conjuga/Scripts/guide-enrichment/drafts/<topicId>.md`.
|
||||
2. **Self-review** — same agent re-reads its own draft against the checklist (TL;DR present? mnemonic present? contrast pair? top 3 pitfalls?). Notes anything it couldn't find a source for.
|
||||
3. **Integrate** — a script reads the draft, swaps it into `conjuga_data.json` (for tense guides) or `GrammarNote.swift` (for grammar notes), bumps `courseDataVersion`, runs build to verify.
|
||||
4. **Spot-check** — user opens the topic in the app on device, reads it, flags anything that feels wrong or missing.
|
||||
5. **Commit** — one commit per topic, message: "Guide enrichment — <topic> (tier N)".
|
||||
|
||||
Batching: do tier-1 topics one at a time so the user can review and shape what "thorough enough" looks like. Tiers 2 and 3 can batch 3–5 topics per session once the format is dialed in.
|
||||
|
||||
## Tooling
|
||||
|
||||
Two small scripts will speed this up:
|
||||
|
||||
- **`enrich_topic.py <topicId>`** — opens the current body, writes a Markdown template at `drafts/<topicId>.md` with the section headers pre-filled, and prints a research prompt the user can hand to a subagent.
|
||||
- **`apply_draft.py <topicId>`** — reads `drafts/<topicId>.md`, validates the section structure, swaps it into `conjuga_data.json` (or `GrammarNote.swift` for grammar notes), bumps `courseDataVersion`.
|
||||
|
||||
Build both when starting tier 1. Don't build them speculatively now.
|
||||
|
||||
## Effort estimate
|
||||
|
||||
- Tier 1 (5 topics): ~30 min research + 30 min draft + 15 min integrate = **~75 min per topic, ~6 hours total**.
|
||||
- Tier 2 (4 topics): faster once the format is dialed in. ~45 min each, ~3 hours.
|
||||
- Tier 3 (11 topics): ~30 min each (most are compound tenses with similar structure), ~5 hours.
|
||||
- Grammar notes audit + fill: ~10 min audit each × 36 = 6 hours; ~30 min fill on the ~10 that need it = 5 hours. Total ~11 hours.
|
||||
|
||||
**Total scoped at ~25 hours.** Spread across sessions: maybe one tier-1 topic per session, two tier-2 or three tier-3 per session once the format's locked in.
|
||||
|
||||
## Ship plan
|
||||
|
||||
- Each commit is one topic enriched. Small, reviewable diffs.
|
||||
- `courseDataVersion` bumps per commit so the change propagates on next launch.
|
||||
- The user can preview new bodies via the in-app Guide tab without needing a redeploy after the commit hits gitea — they just need to rebuild + reinstall.
|
||||
- The plan doc itself lives here so future sessions can pick up where this one left off without needing to re-derive the structure.
|
||||
|
||||
## Out of scope (intentional)
|
||||
|
||||
- Audio recordings of example sentences (could be a future TTS pre-bake).
|
||||
- Per-region variants (Latin American vs Castilian usage notes) — flag when they matter (vosotros, leísmo), don't comprehensively document.
|
||||
- Interactive exercises tied to each guide (separate Tests/Quiz infrastructure exists; cross-link instead of duplicate).
|
||||
- Translation of the guides into Spanish (current guides are English-explanation, Spanish-examples; keep that asymmetry).
|
||||
- A complete grammar-textbook rewrite. Stop at "depth a teacher would hand out as supplementary material."
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply enriched bodies from drafts/out/ back into the live source files.
|
||||
|
||||
Tense guides → Conjuga/Conjuga/conjuga_data.json (tenseGuides[].body)
|
||||
Grammar notes → Conjuga/Conjuga/Models/GrammarNote.swift (body: \"\"\"...\"\"\")
|
||||
|
||||
Filename conventions in drafts/out/:
|
||||
tense__<tenseId>.md — body to drop into the matching tenseGuide
|
||||
note__<noteId>.md — body to drop into the matching GrammarNote(...) declaration
|
||||
|
||||
Run from anywhere; uses absolute paths anchored at the repo root.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path('/Users/m4mini/Desktop/code/Spanish')
|
||||
OUT_DIR = REPO / 'Conjuga/Scripts/guide-enrichment/out'
|
||||
TENSE_JSON = REPO / 'Conjuga/Conjuga/conjuga_data.json'
|
||||
NOTES_SWIFT = REPO / 'Conjuga/Conjuga/Models/GrammarNote.swift'
|
||||
|
||||
|
||||
def read_draft(path: Path) -> str:
|
||||
"""Drafts may start with comment blocks like `# Title: ...`, `# Category:
|
||||
...`, and `# ENRICHED BODY` separated by blank lines. Strip every leading
|
||||
line that is blank or starts with `#` until we reach the actual body."""
|
||||
raw = path.read_text(encoding='utf-8')
|
||||
lines = raw.splitlines()
|
||||
start = 0
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped == '' or stripped.startswith('#'):
|
||||
start = i + 1
|
||||
continue
|
||||
# Hit the first real content line — keep everything from here.
|
||||
start = i
|
||||
break
|
||||
body = '\n'.join(lines[start:]).strip()
|
||||
if not body:
|
||||
raise ValueError(f'Empty body after stripping header in {path}')
|
||||
return body
|
||||
|
||||
|
||||
def apply_tense_guides() -> int:
|
||||
data = json.loads(TENSE_JSON.read_text(encoding='utf-8'))
|
||||
drafts = sorted(OUT_DIR.glob('tense__*.md'))
|
||||
by_id = {g['tenseId']: g for g in data['tenseGuides']}
|
||||
applied = 0
|
||||
for path in drafts:
|
||||
tense_id = path.stem.removeprefix('tense__')
|
||||
if tense_id not in by_id:
|
||||
print(f' SKIP {tense_id}: not in tenseGuides', file=sys.stderr)
|
||||
continue
|
||||
body = read_draft(path)
|
||||
by_id[tense_id]['body'] = body
|
||||
applied += 1
|
||||
print(f' applied tense: {tense_id} ({len(body)} chars)')
|
||||
TENSE_JSON.write_text(
|
||||
json.dumps(data, ensure_ascii=False, separators=(',', ':')),
|
||||
encoding='utf-8'
|
||||
)
|
||||
return applied
|
||||
|
||||
|
||||
# Match each GrammarNote(...) declaration. Body uses """...""" — may contain
|
||||
# anything except a triple-quote.
|
||||
NOTE_PATTERN = re.compile(
|
||||
r'(GrammarNote\(\s*id:\s*"([^"]+)",\s*'
|
||||
r'title:\s*"(?:[^"\\]|\\.)*",\s*'
|
||||
r'category:\s*"[^"]+",\s*'
|
||||
r'body:\s*""")(.*?)(""")',
|
||||
re.DOTALL
|
||||
)
|
||||
|
||||
|
||||
def apply_grammar_notes() -> int:
|
||||
src = NOTES_SWIFT.read_text(encoding='utf-8')
|
||||
drafts = sorted(OUT_DIR.glob('note__*.md'))
|
||||
by_id = {p.stem.removeprefix('note__'): p for p in drafts}
|
||||
applied = [0]
|
||||
|
||||
def replace_match(m):
|
||||
prefix, note_id, _, suffix = m.group(1), m.group(2), m.group(3), m.group(4) if m.lastindex and m.lastindex >= 4 else m.group(3)
|
||||
return m.group(0) # placeholder, see real callback below
|
||||
|
||||
def real_replace(m):
|
||||
prefix = m.group(1)
|
||||
note_id = m.group(2)
|
||||
suffix = m.group(4)
|
||||
if note_id not in by_id:
|
||||
return m.group(0)
|
||||
body = read_draft(by_id[note_id])
|
||||
if '"""' in body:
|
||||
raise ValueError(f'Body for {note_id} contains triple-quote — would break Swift parser')
|
||||
# Re-indent to match the existing Swift block. The existing format uses
|
||||
# 8 spaces of leading indent inside body lines. We don't enforce that —
|
||||
# the Swift compiler handles multiline string indentation by stripping
|
||||
# the leading whitespace common to all lines based on the closing """.
|
||||
# Just write the body verbatim.
|
||||
applied[0] += 1
|
||||
print(f' applied note: {note_id} ({len(body)} chars)')
|
||||
return f'{prefix}\n{body}\n{suffix}'
|
||||
|
||||
new_src = NOTE_PATTERN.sub(real_replace, src)
|
||||
NOTES_SWIFT.write_text(new_src, encoding='utf-8')
|
||||
return applied[0]
|
||||
|
||||
|
||||
def main():
|
||||
if not OUT_DIR.exists():
|
||||
print(f'No drafts/out directory at {OUT_DIR}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f'=== Tense guides ===')
|
||||
tense_count = apply_tense_guides()
|
||||
print(f'\n=== Grammar notes ===')
|
||||
note_count = apply_grammar_notes()
|
||||
|
||||
print(f'\nTotal applied: {tense_count} tense guides + {note_count} grammar notes')
|
||||
if tense_count == 0 and note_count == 0:
|
||||
print('Nothing applied — drafts/out/ was empty.', file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,374 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merge chapters.json + answers.json + ocr.json → book.json (single source).
|
||||
|
||||
Also emits vocab_cards.json: flashcards derived from vocab_image blocks where
|
||||
OCR text parses as a clean two-column (Spanish ↔ English) table.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
CHAPTERS_JSON = HERE / "chapters.json"
|
||||
ANSWERS_JSON = HERE / "answers.json"
|
||||
OCR_JSON = HERE / "ocr.json"
|
||||
OUT_BOOK = HERE / "book.json"
|
||||
OUT_VOCAB = HERE / "vocab_cards.json"
|
||||
|
||||
COURSE_NAME = "Complete Spanish Step-by-Step"
|
||||
|
||||
# Heuristic: parseable "Spanish | English" vocab rows.
|
||||
# OCR usually produces "word — translation" or "word translation" separated
|
||||
# by 2+ spaces. We detect rows that contain both Spanish and English words.
|
||||
SPANISH_ACCENT_RE = re.compile(r"[áéíóúñüÁÉÍÓÚÑÜ¿¡]")
|
||||
SPANISH_ARTICLES = {"el", "la", "los", "las", "un", "una", "unos", "unas"}
|
||||
ENGLISH_STARTERS = {"the", "a", "an", "to", "my", "his", "her", "our", "their", "your", "some"}
|
||||
# English-only words that would never appear as Spanish
|
||||
ENGLISH_ONLY_WORDS = {"the", "he", "she", "it", "we", "they", "I", "is", "are", "was", "were",
|
||||
"been", "have", "has", "had", "will", "would", "should", "could"}
|
||||
SEP_RE = re.compile(r"[ \t]{2,}|\s[—–−-]\s")
|
||||
|
||||
|
||||
def classify_line(line: str) -> str:
|
||||
"""Return 'es', 'en', or 'unknown' for the dominant language of a vocab line."""
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return "unknown"
|
||||
# Accent = definitely Spanish
|
||||
if SPANISH_ACCENT_RE.search(line):
|
||||
return "es"
|
||||
first = line.split()[0].lower().strip(",.;:")
|
||||
if first in SPANISH_ARTICLES:
|
||||
return "es"
|
||||
if first in ENGLISH_STARTERS:
|
||||
return "en"
|
||||
# Check if the leading word is an English-only function word
|
||||
if first in ENGLISH_ONLY_WORDS:
|
||||
return "en"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def looks_english(word: str) -> bool:
|
||||
"""Legacy helper — kept for try_split_row below."""
|
||||
w = word.lower().strip()
|
||||
if not w:
|
||||
return False
|
||||
if SPANISH_ACCENT_RE.search(w):
|
||||
return False
|
||||
if w in SPANISH_ARTICLES:
|
||||
return False
|
||||
if w in ENGLISH_STARTERS or w in ENGLISH_ONLY_WORDS:
|
||||
return True
|
||||
return bool(re.match(r"^[a-z][a-z\s'/()\-,.]*$", w))
|
||||
|
||||
|
||||
def try_split_row(line: str) -> "tuple[str, str] | None":
|
||||
"""Split a line into (spanish, english) if it looks like a vocab entry."""
|
||||
line = line.strip()
|
||||
if not line or len(line) < 3:
|
||||
return None
|
||||
# Try explicit separators first
|
||||
parts = SEP_RE.split(line)
|
||||
parts = [p.strip() for p in parts if p.strip()]
|
||||
if len(parts) == 2:
|
||||
spanish, english = parts
|
||||
if looks_english(english) and not looks_english(spanish.split()[0]):
|
||||
return (spanish, english)
|
||||
return None
|
||||
|
||||
|
||||
def load(p: Path) -> dict:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def build_vocab_cards_for_block(block: dict, ocr_entry: dict, chapter: dict, context_title: str, idx: int) -> list:
|
||||
"""Given a vocab_image block + its OCR lines, derive flashcards.
|
||||
|
||||
Vision OCR reads top-to-bottom, left-to-right; a two-column vocab table
|
||||
produces Spanish lines first, then English lines. We split the list in
|
||||
half when one side is predominantly Spanish and the other English.
|
||||
Per-line '—' separators are also supported as a fallback.
|
||||
"""
|
||||
cards = []
|
||||
if not ocr_entry:
|
||||
return cards
|
||||
lines = [l.strip() for l in ocr_entry.get("lines", []) if l.strip()]
|
||||
if not lines:
|
||||
return cards
|
||||
|
||||
def card(front: str, back: str) -> dict:
|
||||
return {
|
||||
"front": front,
|
||||
"back": back,
|
||||
"chapter": chapter["number"],
|
||||
"chapterTitle": chapter["title"],
|
||||
"section": context_title,
|
||||
"sourceImage": block["src"],
|
||||
}
|
||||
|
||||
# Attempt 1: explicit inline separator (e.g. "la casa — the house")
|
||||
inline = []
|
||||
all_inline = True
|
||||
for line in lines:
|
||||
pair = try_split_row(line)
|
||||
if pair:
|
||||
inline.append(pair)
|
||||
else:
|
||||
all_inline = False
|
||||
break
|
||||
if all_inline and inline:
|
||||
for es, en in inline:
|
||||
cards.append(card(es, en))
|
||||
return cards
|
||||
|
||||
# Attempt 2: block-alternating layout.
|
||||
# Vision OCR reads columns top-to-bottom, so a 2-col table rendered across
|
||||
# 2 visual columns produces runs like: [ES...ES][EN...EN][ES...ES][EN...EN]
|
||||
# We classify each line, smooth "unknown" using neighbors, then pair
|
||||
# same-sized consecutive ES/EN blocks.
|
||||
classes = [classify_line(l) for l in lines]
|
||||
|
||||
# Pass 1: fill unknowns using nearest non-unknown neighbor (forward)
|
||||
last_known = "unknown"
|
||||
forward = []
|
||||
for c in classes:
|
||||
if c != "unknown":
|
||||
last_known = c
|
||||
forward.append(last_known)
|
||||
# Pass 2: backfill leading unknowns (backward)
|
||||
last_known = "unknown"
|
||||
backward = [""] * len(classes)
|
||||
for i in range(len(classes) - 1, -1, -1):
|
||||
if classes[i] != "unknown":
|
||||
last_known = classes[i]
|
||||
backward[i] = last_known
|
||||
# Merge: prefer forward unless still unknown
|
||||
resolved = []
|
||||
for f, b in zip(forward, backward):
|
||||
if f != "unknown":
|
||||
resolved.append(f)
|
||||
elif b != "unknown":
|
||||
resolved.append(b)
|
||||
else:
|
||||
resolved.append("unknown")
|
||||
|
||||
# Group consecutive same-lang lines
|
||||
blocks: list = []
|
||||
cur_lang: "str | None" = None
|
||||
cur_block: list = []
|
||||
for line, lang in zip(lines, resolved):
|
||||
if lang != cur_lang:
|
||||
if cur_block and cur_lang is not None:
|
||||
blocks.append((cur_lang, cur_block))
|
||||
cur_block = [line]
|
||||
cur_lang = lang
|
||||
else:
|
||||
cur_block.append(line)
|
||||
if cur_block and cur_lang is not None:
|
||||
blocks.append((cur_lang, cur_block))
|
||||
|
||||
# Walk blocks pairing ES then EN of equal length
|
||||
i = 0
|
||||
while i < len(blocks) - 1:
|
||||
lang_a, lines_a = blocks[i]
|
||||
lang_b, lines_b = blocks[i + 1]
|
||||
if lang_a == "es" and lang_b == "en" and len(lines_a) == len(lines_b):
|
||||
for es, en in zip(lines_a, lines_b):
|
||||
cards.append(card(es, en))
|
||||
i += 2
|
||||
continue
|
||||
# If reversed order (some pages have EN column on left), try that too
|
||||
if lang_a == "en" and lang_b == "es" and len(lines_a) == len(lines_b):
|
||||
for es, en in zip(lines_b, lines_a):
|
||||
cards.append(card(es, en))
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
|
||||
return cards
|
||||
|
||||
|
||||
def clean_instruction(text: str) -> str:
|
||||
"""Strip leading/trailing emphasis markers from a parsed instruction."""
|
||||
# Our XHTML parser emitted * and ** for emphasis; flatten them
|
||||
t = re.sub(r"\*+", "", text)
|
||||
return t.strip()
|
||||
|
||||
|
||||
def merge() -> None:
|
||||
chapters_data = load(CHAPTERS_JSON)
|
||||
answers_data = load(ANSWERS_JSON)
|
||||
try:
|
||||
ocr_data = load(OCR_JSON)
|
||||
except FileNotFoundError:
|
||||
print("ocr.json not found — proceeding with empty OCR data")
|
||||
ocr_data = {}
|
||||
|
||||
answers = answers_data["answers"]
|
||||
chapters = chapters_data["chapters"]
|
||||
parts = chapters_data.get("part_memberships", {})
|
||||
|
||||
book_chapters = []
|
||||
all_vocab_cards = []
|
||||
missing_ocr = set()
|
||||
current_section_title = ""
|
||||
|
||||
for ch in chapters:
|
||||
out_blocks = []
|
||||
current_section_title = ch["title"]
|
||||
|
||||
for bi, block in enumerate(ch["blocks"]):
|
||||
k = block["kind"]
|
||||
|
||||
if k == "heading":
|
||||
current_section_title = block["text"]
|
||||
out_blocks.append(block)
|
||||
continue
|
||||
|
||||
if k == "paragraph":
|
||||
out_blocks.append(block)
|
||||
continue
|
||||
|
||||
if k == "key_vocab_header":
|
||||
out_blocks.append(block)
|
||||
continue
|
||||
|
||||
if k == "vocab_image":
|
||||
ocr_entry = ocr_data.get(block["src"])
|
||||
if ocr_entry is None:
|
||||
missing_ocr.add(block["src"])
|
||||
derived = build_vocab_cards_for_block(
|
||||
block, ocr_entry, ch, current_section_title, bi
|
||||
)
|
||||
all_vocab_cards.extend(derived)
|
||||
out_blocks.append({
|
||||
"kind": "vocab_table",
|
||||
"sourceImage": block["src"],
|
||||
"ocrLines": ocr_entry.get("lines", []) if ocr_entry else [],
|
||||
"ocrConfidence": ocr_entry.get("confidence", 0.0) if ocr_entry else 0.0,
|
||||
"cardCount": len(derived),
|
||||
})
|
||||
continue
|
||||
|
||||
if k == "exercise":
|
||||
ans = answers.get(block["id"])
|
||||
image_ocr_lines = []
|
||||
for src in block.get("image_refs", []):
|
||||
e = ocr_data.get(src)
|
||||
if e is None:
|
||||
missing_ocr.add(src)
|
||||
continue
|
||||
image_ocr_lines.extend(e.get("lines", []))
|
||||
|
||||
# Build the final prompt list. If we have text prompts from
|
||||
# XHTML, prefer them. Otherwise, attempt to use OCR lines.
|
||||
prompts = [p for p in block.get("prompts", []) if p.strip()]
|
||||
extras = [e for e in block.get("extra", []) if e.strip()]
|
||||
if not prompts and image_ocr_lines:
|
||||
# Extract numbered lines from OCR (look for "1. ..." pattern)
|
||||
for line in image_ocr_lines:
|
||||
m = re.match(r"^(\d+)[.)]\s*(.+)", line.strip())
|
||||
if m:
|
||||
prompts.append(f"{m.group(1)}. {m.group(2)}")
|
||||
|
||||
# Cross-reference prompts with answers
|
||||
sub = ans["subparts"] if ans else []
|
||||
answer_items = []
|
||||
for sp in sub:
|
||||
for it in sp["items"]:
|
||||
answer_items.append({
|
||||
"label": sp["label"],
|
||||
"number": it["number"],
|
||||
"answer": it["answer"],
|
||||
"alternates": it["alternates"],
|
||||
})
|
||||
|
||||
out_blocks.append({
|
||||
"kind": "exercise",
|
||||
"id": block["id"],
|
||||
"ansAnchor": block.get("ans_anchor", ""),
|
||||
"instruction": clean_instruction(block.get("instruction", "")),
|
||||
"extra": extras,
|
||||
"prompts": prompts,
|
||||
"ocrLines": image_ocr_lines,
|
||||
"freeform": ans["freeform"] if ans else False,
|
||||
"answerItems": answer_items,
|
||||
"answerRaw": ans["raw"] if ans else "",
|
||||
"answerSubparts": sub,
|
||||
})
|
||||
continue
|
||||
|
||||
out_blocks.append(block)
|
||||
|
||||
book_chapters.append({
|
||||
"id": ch["id"],
|
||||
"number": ch["number"],
|
||||
"title": ch["title"],
|
||||
"part": ch.get("part"),
|
||||
"blocks": out_blocks,
|
||||
})
|
||||
|
||||
book = {
|
||||
"courseName": COURSE_NAME,
|
||||
"totalChapters": len(book_chapters),
|
||||
"totalExercises": sum(
|
||||
1 for ch in book_chapters for b in ch["blocks"] if b["kind"] == "exercise"
|
||||
),
|
||||
"totalVocabTables": sum(
|
||||
1 for ch in book_chapters for b in ch["blocks"] if b["kind"] == "vocab_table"
|
||||
),
|
||||
"totalVocabCards": len(all_vocab_cards),
|
||||
"parts": parts,
|
||||
"chapters": book_chapters,
|
||||
}
|
||||
OUT_BOOK.write_text(json.dumps(book, ensure_ascii=False))
|
||||
|
||||
# Vocab cards as a separate file (grouped per chapter so they can be seeded
|
||||
# as CourseDecks in the existing schema).
|
||||
vocab_by_chapter: dict = {}
|
||||
for card in all_vocab_cards:
|
||||
vocab_by_chapter.setdefault(card["chapter"], []).append(card)
|
||||
OUT_VOCAB.write_text(json.dumps({
|
||||
"courseName": COURSE_NAME,
|
||||
"chapters": [
|
||||
{
|
||||
"chapter": ch_num,
|
||||
"cards": cards,
|
||||
}
|
||||
for ch_num, cards in sorted(vocab_by_chapter.items())
|
||||
],
|
||||
}, ensure_ascii=False, indent=2))
|
||||
|
||||
# Summary
|
||||
print(f"Wrote {OUT_BOOK}")
|
||||
print(f"Wrote {OUT_VOCAB}")
|
||||
print(f"Chapters: {book['totalChapters']}")
|
||||
print(f"Exercises: {book['totalExercises']}")
|
||||
print(f"Vocab tables: {book['totalVocabTables']}")
|
||||
print(f"Vocab cards (auto): {book['totalVocabCards']}")
|
||||
if missing_ocr:
|
||||
print(f"Missing OCR for {len(missing_ocr)} images (first 5): {sorted(list(missing_ocr))[:5]}")
|
||||
|
||||
# Validation
|
||||
total_exercises = book["totalExercises"]
|
||||
exercises_with_prompts = sum(
|
||||
1 for ch in book_chapters for b in ch["blocks"]
|
||||
if b["kind"] == "exercise" and (b["prompts"] or b["extra"])
|
||||
)
|
||||
exercises_with_answers = sum(
|
||||
1 for ch in book_chapters for b in ch["blocks"]
|
||||
if b["kind"] == "exercise" and b["answerItems"]
|
||||
)
|
||||
exercises_freeform = sum(
|
||||
1 for ch in book_chapters for b in ch["blocks"]
|
||||
if b["kind"] == "exercise" and b["freeform"]
|
||||
)
|
||||
print(f"Exercises with prompts: {exercises_with_prompts}/{total_exercises}")
|
||||
print(f"Exercises with answers: {exercises_with_answers}/{total_exercises}")
|
||||
print(f"Freeform exercises: {exercises_freeform}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
merge()
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render book.json + ocr.json into a static HTML review page.
|
||||
|
||||
The HTML surfaces low-confidence OCR results in red, and shows the parsed
|
||||
exercise prompts/answers next to the original image. Designed for rapid
|
||||
visual diffing against the source book.
|
||||
"""
|
||||
|
||||
import html
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
BOOK = HERE / "book.json"
|
||||
OCR = HERE / "ocr.json"
|
||||
OUT_HTML = HERE / "review.html"
|
||||
EPUB_IMAGES = Path(HERE).parents[2] / "epub_extract" / "OEBPS"
|
||||
IMAGE_REL = EPUB_IMAGES.relative_to(HERE.parent) if False else EPUB_IMAGES
|
||||
|
||||
|
||||
def load(p: Path) -> dict:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def esc(s: str) -> str:
|
||||
return html.escape(s or "")
|
||||
|
||||
|
||||
def img_tag(src: str) -> str:
|
||||
full = (EPUB_IMAGES / src).resolve()
|
||||
return f'<img src="file://{full}" alt="{esc(src)}" class="src"/>'
|
||||
|
||||
|
||||
def render() -> None:
|
||||
book = load(BOOK)
|
||||
ocr = load(OCR) if OCR.exists() else {}
|
||||
|
||||
out: list = []
|
||||
out.append("""<!DOCTYPE html>
|
||||
<html><head><meta charset='utf-8'><title>Book review</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, system-ui, sans-serif; margin: 2em; max-width: 1000px; color: #222; }
|
||||
h1 { color: #c44; }
|
||||
h2.chapter { background: #eee; padding: 0.5em; border-left: 4px solid #c44; }
|
||||
h3.heading { color: #555; }
|
||||
.para { margin: 0.5em 0; }
|
||||
.vocab-table { background: #fafff0; padding: 0.5em; margin: 0.5em 0; border: 1px solid #bda; border-radius: 6px; }
|
||||
.ocr-line { font-family: ui-monospace, monospace; font-size: 12px; }
|
||||
.lowconf { color: #c44; background: #fee; }
|
||||
.exercise { background: #fff8e8; padding: 0.5em; margin: 0.75em 0; border: 1px solid #cb9; border-radius: 6px; }
|
||||
.prompt { font-family: ui-monospace, monospace; font-size: 13px; margin: 2px 0; }
|
||||
.answer { color: #080; font-family: ui-monospace, monospace; font-size: 13px; }
|
||||
img.src { max-width: 520px; border: 1px solid #ccc; margin: 4px 0; }
|
||||
.kv { color: #04a; font-weight: bold; }
|
||||
summary { cursor: pointer; font-weight: bold; color: #666; }
|
||||
.card-pair { font-family: ui-monospace, monospace; font-size: 12px; }
|
||||
.card-es { color: #04a; }
|
||||
.card-en { color: #555; }
|
||||
.counts { color: #888; font-size: 12px; }
|
||||
</style></head><body>""")
|
||||
out.append(f"<h1>{esc(book['courseName'])} — review</h1>")
|
||||
out.append(f"<p>{book['totalChapters']} chapters · {book['totalExercises']} exercises · {book['totalVocabTables']} vocab tables · {book['totalVocabCards']} auto-derived cards</p>")
|
||||
|
||||
for ch in book["chapters"]:
|
||||
part = ch.get("part")
|
||||
part_str = f" (Part {part})" if part else ""
|
||||
out.append(f"<h2 class='chapter'>Chapter {ch['number']}: {esc(ch['title'])}{esc(part_str)}</h2>")
|
||||
|
||||
for b in ch["blocks"]:
|
||||
kind = b["kind"]
|
||||
if kind == "heading":
|
||||
level = b["level"]
|
||||
out.append(f"<h{level} class='heading'>{esc(b['text'])}</h{level}>")
|
||||
elif kind == "paragraph":
|
||||
out.append(f"<p class='para'>{esc(b['text'])}</p>")
|
||||
elif kind == "key_vocab_header":
|
||||
out.append(f"<p class='kv'>★ Key Vocabulary</p>")
|
||||
elif kind == "vocab_table":
|
||||
src = b["sourceImage"]
|
||||
conf = b["ocrConfidence"]
|
||||
conf_class = "lowconf" if conf < 0.85 else ""
|
||||
out.append(f"<div class='vocab-table'>")
|
||||
out.append(f"<details><summary>vocab {esc(src)} · confidence {conf:.2f} · {b['cardCount']} card(s)</summary>")
|
||||
out.append(img_tag(src))
|
||||
out.append("<div>")
|
||||
for line in b.get("ocrLines", []):
|
||||
out.append(f"<div class='ocr-line {conf_class}'>{esc(line)}</div>")
|
||||
out.append("</div>")
|
||||
# Show derived pairs (if any). We don't have them inline in book.json,
|
||||
# but we can recompute from ocrLines using the same function.
|
||||
out.append("</details></div>")
|
||||
elif kind == "exercise":
|
||||
out.append(f"<div class='exercise'>")
|
||||
out.append(f"<b>Exercise {esc(b['id'])}</b> — <i>{esc(b['instruction'])}</i>")
|
||||
if b.get("extra"):
|
||||
for e in b["extra"]:
|
||||
out.append(f"<div class='para'>{esc(e)}</div>")
|
||||
if b.get("ocrLines"):
|
||||
out.append(f"<details><summary>OCR lines from image</summary>")
|
||||
for line in b["ocrLines"]:
|
||||
out.append(f"<div class='ocr-line'>{esc(line)}</div>")
|
||||
out.append("</details>")
|
||||
if b.get("prompts"):
|
||||
out.append("<div><b>Parsed prompts:</b></div>")
|
||||
for p in b["prompts"]:
|
||||
out.append(f"<div class='prompt'>• {esc(p)}</div>")
|
||||
if b.get("answerItems"):
|
||||
out.append("<div><b>Answer key:</b></div>")
|
||||
for a in b["answerItems"]:
|
||||
label_str = f"{a['label']}. " if a.get("label") else ""
|
||||
alts = ", ".join(a["alternates"])
|
||||
alt_str = f" <span style='color:#999'>(also: {esc(alts)})</span>" if alts else ""
|
||||
out.append(f"<div class='answer'>{esc(label_str)}{a['number']}. {esc(a['answer'])}{alt_str}</div>")
|
||||
if b.get("freeform"):
|
||||
out.append("<div style='color:#c44'>(Freeform — answers will vary)</div>")
|
||||
for img_src in b.get("image_refs", []):
|
||||
out.append(img_tag(img_src))
|
||||
out.append("</div>")
|
||||
|
||||
out.append("</body></html>")
|
||||
OUT_HTML.write_text("\n".join(out), encoding="utf-8")
|
||||
print(f"Wrote {OUT_HTML}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
render()
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse ans.xhtml into structured answers.json.
|
||||
|
||||
Output schema:
|
||||
{
|
||||
"answers": {
|
||||
"1.1": {
|
||||
"id": "1.1",
|
||||
"anchor": "ch1ans1",
|
||||
"chapter": 1,
|
||||
"subparts": [
|
||||
{"label": null, "items": [
|
||||
{"number": 1, "answer": "el", "alternates": []},
|
||||
{"number": 2, "answer": "el", "alternates": []},
|
||||
...
|
||||
]}
|
||||
],
|
||||
"freeform": false, # true if "Answers will vary"
|
||||
"raw": "..." # raw text for fallback
|
||||
},
|
||||
"2.4": { # multi-part exercise
|
||||
"subparts": [
|
||||
{"label": "A", "items": [...]},
|
||||
{"label": "B", "items": [...]},
|
||||
{"label": "C", "items": [...]}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from bs4 import BeautifulSoup, NavigableString
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3] / "epub_extract" / "OEBPS"
|
||||
OUT = Path(__file__).resolve().parent / "answers.json"
|
||||
|
||||
ANSWER_CLASSES = {"answerq", "answerq1", "answerq2", "answerqa"}
|
||||
EXERCISE_ID_RE = re.compile(r"^([0-9]+)\.([0-9]+)$")
|
||||
SUBPART_LABEL_RE = re.compile(r"^([A-Z])\b")
|
||||
NUMBERED_ITEM_RE = re.compile(r"(?:^|\s)(\d+)\.\s+")
|
||||
FREEFORM_PATTERNS = [
|
||||
re.compile(r"answers? will vary", re.IGNORECASE),
|
||||
re.compile(r"answer will vary", re.IGNORECASE),
|
||||
]
|
||||
OR_TOKEN = "{{OR}}"
|
||||
|
||||
|
||||
def render_with_or(p) -> str:
|
||||
"""Convert <p> to plain text, replacing 'OR' span markers with sentinel."""
|
||||
soup = BeautifulSoup(str(p), "lxml")
|
||||
# Replace <span class="small">OR</span> with sentinel
|
||||
for span in soup.find_all("span"):
|
||||
cls = span.get("class") or []
|
||||
if "small" in cls and span.get_text(strip=True).upper() == "OR":
|
||||
span.replace_with(f" {OR_TOKEN} ")
|
||||
# Drop pagebreak spans
|
||||
for span in soup.find_all("span", attrs={"epub:type": "pagebreak"}):
|
||||
span.decompose()
|
||||
# Drop emphasis but keep text
|
||||
for tag in soup.find_all(["em", "i", "strong", "b"]):
|
||||
tag.unwrap()
|
||||
text = soup.get_text(separator=" ", strip=False)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
return text
|
||||
|
||||
|
||||
def split_numbered_items(text: str) -> "list[dict]":
|
||||
"""Given '1. el 2. la 3. el ...' return [{'number':1,'answer':'el'}, ...]."""
|
||||
# Find positions of N. tokens
|
||||
matches = list(NUMBERED_ITEM_RE.finditer(text))
|
||||
items = []
|
||||
for i, m in enumerate(matches):
|
||||
num = int(m.group(1))
|
||||
start = m.end()
|
||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||||
body = text[start:end].strip().rstrip(".,;")
|
||||
# Split alternates on the OR token
|
||||
parts = [p.strip() for p in body.split(OR_TOKEN) if p.strip()]
|
||||
if not parts:
|
||||
continue
|
||||
items.append({
|
||||
"number": num,
|
||||
"answer": parts[0],
|
||||
"alternates": parts[1:],
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def parse_subpart_label(text: str) -> "tuple[str | None, str]":
|
||||
"""Try to peel a leading subpart label (A, B, C) from the text.
|
||||
Returns (label_or_None, remaining_text)."""
|
||||
# Pattern at start: "A " or "A " (lots of whitespace from <em>A</em><tab>)
|
||||
m = re.match(r"^([A-Z])\s+(?=\d)", text)
|
||||
if m:
|
||||
return m.group(1), text[m.end():]
|
||||
return None, text
|
||||
|
||||
|
||||
def parse_answer_paragraph(p, exercise_id: str) -> "list[dict]":
|
||||
"""Convert one <p> into a list of subparts.
|
||||
For p.answerq, the text typically starts with the exercise id, then items.
|
||||
For p.answerqa, the text starts with a subpart label letter."""
|
||||
raw = render_with_or(p)
|
||||
# Strip the leading exercise id if present
|
||||
raw = re.sub(rf"^{re.escape(exercise_id)}\s*", "", raw)
|
||||
|
||||
label, body = parse_subpart_label(raw)
|
||||
|
||||
# Detect freeform
|
||||
freeform = any(pat.search(body) for pat in FREEFORM_PATTERNS)
|
||||
if freeform:
|
||||
return [{"label": label, "items": [], "freeform": True, "raw": body}]
|
||||
|
||||
items = split_numbered_items(body)
|
||||
return [{"label": label, "items": items, "freeform": False, "raw": body}]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
src = ROOT / "ans.xhtml"
|
||||
soup = BeautifulSoup(src.read_text(encoding="utf-8"), "lxml")
|
||||
body = soup.find("body")
|
||||
|
||||
answers: dict = {}
|
||||
current_chapter = None
|
||||
current_exercise_id: "str | None" = None
|
||||
|
||||
for el in body.find_all(["h3", "p"]):
|
||||
classes = set(el.get("class") or [])
|
||||
|
||||
# Chapter boundary
|
||||
if el.name == "h3" and "h3b" in classes:
|
||||
text = el.get_text(strip=True)
|
||||
m = re.search(r"Chapter\s+(\d+)", text)
|
||||
if m:
|
||||
current_chapter = int(m.group(1))
|
||||
current_exercise_id = None
|
||||
continue
|
||||
|
||||
if el.name != "p" or not (classes & ANSWER_CLASSES):
|
||||
continue
|
||||
|
||||
# Find the exercise-id anchor (only present on p.answerq, not on continuation)
|
||||
a = el.find("a", href=True)
|
||||
ex_link = None
|
||||
if a:
|
||||
link_text = a.get_text(strip=True)
|
||||
if EXERCISE_ID_RE.match(link_text):
|
||||
ex_link = link_text
|
||||
|
||||
if ex_link:
|
||||
current_exercise_id = ex_link
|
||||
anchor = ""
|
||||
href = a.get("href", "")
|
||||
anchor_m = re.search(r"#(ch\d+ans\d+)", href + " " + (a.get("id") or ""))
|
||||
anchor = anchor_m.group(1) if anchor_m else (a.get("id") or "")
|
||||
# Use the anchor's `id` attr if it's the entry id (e.g. "ch1ans1")
|
||||
entry_id = a.get("id") or anchor
|
||||
|
||||
answers[ex_link] = {
|
||||
"id": ex_link,
|
||||
"anchor": entry_id,
|
||||
"chapter": current_chapter,
|
||||
"subparts": [],
|
||||
"freeform": False,
|
||||
"raw": "",
|
||||
}
|
||||
new_subparts = parse_answer_paragraph(el, ex_link)
|
||||
answers[ex_link]["subparts"].extend(new_subparts)
|
||||
answers[ex_link]["raw"] = render_with_or(el)
|
||||
answers[ex_link]["freeform"] = any(sp["freeform"] for sp in new_subparts)
|
||||
else:
|
||||
# Continuation paragraph for current exercise
|
||||
if current_exercise_id and current_exercise_id in answers:
|
||||
more = parse_answer_paragraph(el, current_exercise_id)
|
||||
answers[current_exercise_id]["subparts"].extend(more)
|
||||
if any(sp["freeform"] for sp in more):
|
||||
answers[current_exercise_id]["freeform"] = True
|
||||
|
||||
out = {"answers": answers}
|
||||
OUT.write_text(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
|
||||
total = len(answers)
|
||||
freeform = sum(1 for v in answers.values() if v["freeform"])
|
||||
multipart = sum(1 for v in answers.values() if len(v["subparts"]) > 1)
|
||||
total_items = sum(
|
||||
len(sp["items"]) for v in answers.values() for sp in v["subparts"]
|
||||
)
|
||||
with_alternates = sum(
|
||||
1 for v in answers.values()
|
||||
for sp in v["subparts"] for it in sp["items"]
|
||||
if it["alternates"]
|
||||
)
|
||||
print(f"Exercises with answers: {total}")
|
||||
print(f" freeform: {freeform}")
|
||||
print(f" multi-part (A/B/C): {multipart}")
|
||||
print(f" total numbered items: {total_items}")
|
||||
print(f" items with alternates:{with_alternates}")
|
||||
print(f"Wrote {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,369 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse all chapter XHTMLs + appendix into structured chapters.json.
|
||||
|
||||
Output schema:
|
||||
{
|
||||
"chapters": [
|
||||
{
|
||||
"id": "ch1",
|
||||
"number": 1,
|
||||
"title": "Nouns, Articles, and Adjectives",
|
||||
"part": 1, # part 1/2/3 or null
|
||||
"blocks": [ # ordered content
|
||||
{"kind": "heading", "level": 3, "text": "..."},
|
||||
{"kind": "paragraph", "text": "...", "hasItalic": false},
|
||||
{"kind": "key_vocab_header", "title": "Los colores (The colors)"},
|
||||
{"kind": "vocab_image", "src": "f0010-03.jpg"},
|
||||
{
|
||||
"kind": "exercise",
|
||||
"id": "1.1",
|
||||
"ans_anchor": "ch1ans1",
|
||||
"instruction": "Write the appropriate...",
|
||||
"image_refs": ["f0005-02.jpg"]
|
||||
},
|
||||
{"kind": "image", "src": "...", "alt": "..."}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3] / "epub_extract" / "OEBPS"
|
||||
OUT = Path(__file__).resolve().parent / "chapters.json"
|
||||
|
||||
# Common icon images embedded in headings — ignore when collecting content images
|
||||
ICON_IMAGES = {"Common01.jpg", "Common02.jpg", "Common03.jpg", "Common04.jpg", "Common05.jpg"}
|
||||
|
||||
EXERCISE_ID_RE = re.compile(r"Exercise\s+([0-9]+\.[0-9]+)")
|
||||
ANS_REF_RE = re.compile(r"ch(\d+)ans(\d+)")
|
||||
|
||||
|
||||
def clean_text(el) -> str:
|
||||
"""Extract text preserving inline emphasis markers."""
|
||||
if el is None:
|
||||
return ""
|
||||
# Replace <em>/<i> with markdown-ish *...*, <strong>/<b> with **...**
|
||||
html = str(el)
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
# First: flatten nested emphasis so we don't emit overlapping markers.
|
||||
# For <strong><em>X</em></strong>, drop the inner em (the bold wrapping
|
||||
# already carries the emphasis visually). Same for <em><strong>...</strong></em>.
|
||||
for tag in soup.find_all(["strong", "b"]):
|
||||
for inner in tag.find_all(["em", "i"]):
|
||||
inner.unwrap()
|
||||
for tag in soup.find_all(["em", "i"]):
|
||||
for inner in tag.find_all(["strong", "b"]):
|
||||
inner.unwrap()
|
||||
# Drop ALL inline emphasis. The source has nested/sibling em/strong
|
||||
# patterns that CommonMark can't reliably parse, causing markers to leak
|
||||
# into the UI. Plain text renders cleanly everywhere.
|
||||
for tag in soup.find_all(["em", "i", "strong", "b"]):
|
||||
tag.unwrap()
|
||||
# Drop pagebreak spans
|
||||
for tag in soup.find_all("span", attrs={"epub:type": "pagebreak"}):
|
||||
tag.decompose()
|
||||
# Replace <br/> with newline
|
||||
for br in soup.find_all("br"):
|
||||
br.replace_with("\n")
|
||||
# Use a separator so adjacent inline tags don't concatenate without spaces
|
||||
# (e.g. "<strong><em>Ir</em></strong> and" would otherwise become "Irand").
|
||||
text = soup.get_text(separator=" ", strip=False)
|
||||
# Collapse runs of whitespace first.
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
# Strip any stray asterisks that sneak through (e.g. author's literal *).
|
||||
text = text.replace("*", "")
|
||||
# De-space punctuation
|
||||
text = re.sub(r"\s+([,.;:!?])", r"\1", text)
|
||||
# Tighten brackets that picked up separator-spaces: "( foo )" -> "(foo)"
|
||||
text = re.sub(r"([(\[])\s+", r"\1", text)
|
||||
text = re.sub(r"\s+([)\]])", r"\1", text)
|
||||
# Collapse any double-spaces
|
||||
text = re.sub(r" +", " ", text).strip()
|
||||
return text
|
||||
|
||||
|
||||
def is_exercise_header(h) -> bool:
|
||||
"""Heading with an <a href='ans.xhtml#...'>Exercise N.N</a> link.
|
||||
Chapters 1-16 use h3.h3k; chapters 17+ use h4.h4."""
|
||||
if h.name not in ("h3", "h4"):
|
||||
return False
|
||||
a = h.find("a", href=True)
|
||||
if a and "ans.xhtml" in a["href"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_key_vocab_header(h) -> bool:
|
||||
"""Heading with 'Key Vocabulary' text (no anchor link to answers)."""
|
||||
if h.name not in ("h3", "h4"):
|
||||
return False
|
||||
text = h.get_text(strip=True)
|
||||
if "Key Vocabulary" in text and not h.find("a", href=lambda v: v and "ans.xhtml" in v):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_image_srcs(parent) -> list:
|
||||
"""Return list of image src attributes, skipping icon images."""
|
||||
srcs = []
|
||||
for img in parent.find_all("img"):
|
||||
src = img.get("src", "")
|
||||
if not src or Path(src).name in ICON_IMAGES:
|
||||
continue
|
||||
srcs.append(src)
|
||||
return srcs
|
||||
|
||||
|
||||
def parse_chapter(path: Path) -> "dict | None":
|
||||
"""Parse one chapter file into structured blocks."""
|
||||
html = path.read_text(encoding="utf-8")
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
body = soup.find("body")
|
||||
if body is None:
|
||||
return None
|
||||
|
||||
# Chapter number + title
|
||||
number = None
|
||||
title = ""
|
||||
h2s = body.find_all("h2")
|
||||
for h2 in h2s:
|
||||
classes = h2.get("class") or []
|
||||
# Use a separator so consecutive inline tags don't concatenate
|
||||
# (e.g. "<strong><em>Ir</em></strong> and the Future" → "Ir and the Future")
|
||||
text_with_sep = re.sub(r"\s+", " ", h2.get_text(" ", strip=True))
|
||||
# Strip spaces that were inserted before punctuation
|
||||
text_with_sep = re.sub(r"\s+([,.;:!?])", r"\1", text_with_sep).strip()
|
||||
if "h2c" in classes and text_with_sep.isdigit():
|
||||
number = int(text_with_sep)
|
||||
# Chapters 1–16 use h2c1; chapters 17+ use h2-c
|
||||
elif ("h2c1" in classes or "h2-c" in classes) and not title:
|
||||
title = text_with_sep
|
||||
if number is None:
|
||||
# Try id on chapter header (ch1 → 1)
|
||||
for h2 in h2s:
|
||||
id_ = h2.get("id", "")
|
||||
m = re.match(r"ch(\d+)", id_)
|
||||
if m:
|
||||
number = int(m.group(1))
|
||||
break
|
||||
|
||||
chapter_id = path.stem # ch1, ch2, ...
|
||||
|
||||
# Walk section content in document order
|
||||
section = body.find("section") or body
|
||||
blocks: list = []
|
||||
pending_instruction = None # holds italic paragraph following an exercise header
|
||||
|
||||
for el in section.descendants:
|
||||
if el.name is None:
|
||||
continue
|
||||
|
||||
classes = el.get("class") or []
|
||||
|
||||
# Skip nested tags already captured via parent processing
|
||||
# We operate only on direct h2/h3/h4/h5/p elements
|
||||
if el.name not in ("h2", "h3", "h4", "h5", "p"):
|
||||
continue
|
||||
|
||||
# Exercise header detection (h3 in ch1-16, h4 in ch17+)
|
||||
if is_exercise_header(el):
|
||||
a = el.find("a", href=True)
|
||||
href = a["href"] if a else ""
|
||||
m = EXERCISE_ID_RE.search(el.get_text())
|
||||
ex_id = m.group(1) if m else ""
|
||||
anchor_m = ANS_REF_RE.search(href)
|
||||
ans_anchor = anchor_m.group(0) if anchor_m else ""
|
||||
blocks.append({
|
||||
"kind": "exercise",
|
||||
"id": ex_id,
|
||||
"ans_anchor": ans_anchor,
|
||||
"instruction": "",
|
||||
"image_refs": [],
|
||||
"prompts": []
|
||||
})
|
||||
pending_instruction = blocks[-1]
|
||||
continue
|
||||
|
||||
# Key Vocabulary header
|
||||
if is_key_vocab_header(el):
|
||||
blocks.append({"kind": "key_vocab_header", "title": "Key Vocabulary"})
|
||||
pending_instruction = None
|
||||
continue
|
||||
|
||||
# Other headings
|
||||
if el.name in ("h2", "h3", "h4", "h5"):
|
||||
if el.name == "h2":
|
||||
# Skip the chapter-number/chapter-title h2s we already captured
|
||||
continue
|
||||
txt = clean_text(el)
|
||||
if txt:
|
||||
blocks.append({
|
||||
"kind": "heading",
|
||||
"level": int(el.name[1]),
|
||||
"text": txt,
|
||||
})
|
||||
pending_instruction = None
|
||||
continue
|
||||
|
||||
# Paragraphs
|
||||
if el.name == "p":
|
||||
imgs = extract_image_srcs(el)
|
||||
text = clean_text(el)
|
||||
p_classes = set(classes)
|
||||
|
||||
# Skip pure blank-line class ("nump" = underscore lines under number prompts)
|
||||
if p_classes & {"nump", "numpa"} and not text:
|
||||
continue
|
||||
|
||||
# Exercise prompt: <p class="number">1. Prompt text</p>
|
||||
# Also number1, number2 (continuation numbering), numbera, numbert
|
||||
if pending_instruction is not None and p_classes & {"number", "number1", "number2", "numbera", "numbert"}:
|
||||
if text:
|
||||
pending_instruction["prompts"].append(text)
|
||||
continue
|
||||
|
||||
# Image container for a pending exercise
|
||||
if pending_instruction is not None and imgs and not text:
|
||||
pending_instruction["image_refs"].extend(imgs)
|
||||
continue
|
||||
|
||||
# Instruction line right after the exercise header
|
||||
if pending_instruction is not None and text and not imgs and not pending_instruction["instruction"]:
|
||||
pending_instruction["instruction"] = text
|
||||
continue
|
||||
|
||||
# While in pending-exercise state, extra text paragraphs are word
|
||||
# banks / context ("from the following list:" etc) — keep pending alive.
|
||||
if pending_instruction is not None and text and not imgs:
|
||||
pending_instruction.setdefault("extra", []).append(text)
|
||||
continue
|
||||
|
||||
# Paragraphs that contain an image belong to vocab/key-vocab callouts
|
||||
if imgs and not text:
|
||||
for src in imgs:
|
||||
blocks.append({"kind": "vocab_image", "src": src})
|
||||
continue
|
||||
|
||||
# Mixed paragraph: image with caption
|
||||
if imgs and text:
|
||||
for src in imgs:
|
||||
blocks.append({"kind": "vocab_image", "src": src})
|
||||
blocks.append({"kind": "paragraph", "text": text})
|
||||
continue
|
||||
|
||||
# Plain paragraph — outside any exercise
|
||||
if text:
|
||||
blocks.append({"kind": "paragraph", "text": text})
|
||||
|
||||
return {
|
||||
"id": chapter_id,
|
||||
"number": number,
|
||||
"title": title,
|
||||
"blocks": blocks,
|
||||
}
|
||||
|
||||
|
||||
def assign_parts(chapters: list, part_files: "dict[int, list[int]]") -> None:
|
||||
"""Annotate chapters with part number based on TOC membership."""
|
||||
for part_num, chapter_nums in part_files.items():
|
||||
for ch in chapters:
|
||||
if ch["number"] in chapter_nums:
|
||||
ch["part"] = part_num
|
||||
for ch in chapters:
|
||||
ch.setdefault("part", None)
|
||||
|
||||
|
||||
def read_part_memberships() -> "dict[int, list[int]]":
|
||||
"""Derive part→chapter grouping from the OPF spine order."""
|
||||
opf = next(ROOT.glob("*.opf"), None)
|
||||
if opf is None:
|
||||
return {}
|
||||
soup = BeautifulSoup(opf.read_text(encoding="utf-8"), "xml")
|
||||
memberships: dict = {}
|
||||
current_part: "int | None" = None
|
||||
for item in soup.find_all("item"):
|
||||
href = item.get("href", "")
|
||||
m_part = re.match(r"part(\d+)\.xhtml", href)
|
||||
m_ch = re.match(r"ch(\d+)\.xhtml", href)
|
||||
if m_part:
|
||||
current_part = int(m_part.group(1))
|
||||
memberships.setdefault(current_part, [])
|
||||
elif m_ch and current_part is not None:
|
||||
memberships[current_part].append(int(m_ch.group(1)))
|
||||
# Manifest order tends to match spine order for this book; verify via spine just in case
|
||||
spine = soup.find("spine")
|
||||
if spine is not None:
|
||||
order = []
|
||||
for ref in spine.find_all("itemref"):
|
||||
idref = ref.get("idref")
|
||||
item = soup.find("item", attrs={"id": idref})
|
||||
if item is not None:
|
||||
order.append(item.get("href", ""))
|
||||
# Rebuild from spine order
|
||||
memberships = {}
|
||||
current_part = None
|
||||
for href in order:
|
||||
m_part = re.match(r"part(\d+)\.xhtml", href)
|
||||
m_ch = re.match(r"ch(\d+)\.xhtml", href)
|
||||
if m_part:
|
||||
current_part = int(m_part.group(1))
|
||||
memberships.setdefault(current_part, [])
|
||||
elif m_ch and current_part is not None:
|
||||
memberships[current_part].append(int(m_ch.group(1)))
|
||||
return memberships
|
||||
|
||||
|
||||
def main() -> None:
|
||||
chapter_files = sorted(
|
||||
ROOT.glob("ch*.xhtml"),
|
||||
key=lambda p: int(re.match(r"ch(\d+)", p.stem).group(1))
|
||||
)
|
||||
chapters = []
|
||||
for path in chapter_files:
|
||||
ch = parse_chapter(path)
|
||||
if ch:
|
||||
chapters.append(ch)
|
||||
|
||||
part_memberships = read_part_memberships()
|
||||
assign_parts(chapters, part_memberships)
|
||||
|
||||
out = {
|
||||
"chapters": chapters,
|
||||
"part_memberships": part_memberships,
|
||||
}
|
||||
OUT.write_text(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
|
||||
# Summary
|
||||
ex_total = sum(1 for ch in chapters for b in ch["blocks"] if b["kind"] == "exercise")
|
||||
ex_with_prompts = sum(
|
||||
1 for ch in chapters for b in ch["blocks"]
|
||||
if b["kind"] == "exercise" and b["prompts"]
|
||||
)
|
||||
ex_with_images = sum(
|
||||
1 for ch in chapters for b in ch["blocks"]
|
||||
if b["kind"] == "exercise" and b["image_refs"]
|
||||
)
|
||||
ex_empty = sum(
|
||||
1 for ch in chapters for b in ch["blocks"]
|
||||
if b["kind"] == "exercise" and not b["prompts"] and not b["image_refs"]
|
||||
)
|
||||
para_total = sum(1 for ch in chapters for b in ch["blocks"] if b["kind"] == "paragraph")
|
||||
vocab_img_total = sum(1 for ch in chapters for b in ch["blocks"] if b["kind"] == "vocab_image")
|
||||
print(f"Chapters: {len(chapters)}")
|
||||
print(f"Exercises total: {ex_total}")
|
||||
print(f" with text prompts: {ex_with_prompts}")
|
||||
print(f" with image prompts: {ex_with_images}")
|
||||
print(f" empty: {ex_empty}")
|
||||
print(f"Paragraphs: {para_total}")
|
||||
print(f"Vocab images: {vocab_img_total}")
|
||||
print(f"Parts: {part_memberships}")
|
||||
print(f"Wrote {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract clean text from the PDF source and map each PDF page to the
|
||||
book's printed page number.
|
||||
|
||||
Output: pdf_text.json
|
||||
{
|
||||
"pdfPageCount": 806,
|
||||
"bookPages": {
|
||||
"3": { "text": "...", "pdfIndex": 29 },
|
||||
"4": { ... },
|
||||
...
|
||||
},
|
||||
"unmapped": [list of pdfIndex values with no detectable book page number]
|
||||
}
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
import pypdf
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
PDF = next(
|
||||
Path(__file__).resolve().parents[3].glob("Complete Spanish Step-By-Step*.pdf"),
|
||||
None,
|
||||
)
|
||||
OUT = HERE / "pdf_text.json"
|
||||
|
||||
ROMAN_RE = re.compile(r"^[ivxlcdmIVXLCDM]+$")
|
||||
# Match a page number on its own line at top/bottom of the page.
|
||||
# The book uses Arabic numerals for main chapters (e.g., "3") and Roman for front matter.
|
||||
PAGE_NUM_LINE_RE = re.compile(r"^\s*(\d{1,4})\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
def detect_book_page(text: str) -> "int | None":
|
||||
"""Find the printed page number from standalone page-number lines at the
|
||||
top or bottom of a page."""
|
||||
lines = [l.strip() for l in text.splitlines() if l.strip()]
|
||||
# Check first 2 lines and last 2 lines
|
||||
for candidate in lines[:2] + lines[-2:]:
|
||||
m = re.match(r"^(\d{1,4})$", candidate)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if PDF is None:
|
||||
print("No PDF found in project root")
|
||||
return
|
||||
|
||||
print(f"Reading {PDF.name}")
|
||||
reader = pypdf.PdfReader(str(PDF))
|
||||
pages = reader.pages
|
||||
print(f"PDF has {len(pages)} pages")
|
||||
|
||||
by_book_page: dict = {}
|
||||
unmapped: list = []
|
||||
last_seen: "int | None" = None
|
||||
missed_count = 0
|
||||
|
||||
for i, page in enumerate(pages):
|
||||
text = page.extract_text() or ""
|
||||
book_page = detect_book_page(text)
|
||||
|
||||
if book_page is None:
|
||||
# Carry forward sequence: if we saw page N last, assume N+1.
|
||||
if last_seen is not None:
|
||||
book_page = last_seen + 1
|
||||
missed_count += 1
|
||||
else:
|
||||
unmapped.append(i)
|
||||
continue
|
||||
last_seen = book_page
|
||||
# Strip the detected page number from text to clean the output
|
||||
cleaned = re.sub(r"(?m)^\s*\d{1,4}\s*$", "", text).strip()
|
||||
by_book_page[str(book_page)] = {
|
||||
"text": cleaned,
|
||||
"pdfIndex": i,
|
||||
}
|
||||
|
||||
out = {
|
||||
"pdfPageCount": len(pages),
|
||||
"bookPages": by_book_page,
|
||||
"unmapped": unmapped,
|
||||
"inferredPages": missed_count,
|
||||
}
|
||||
OUT.write_text(json.dumps(out, ensure_ascii=False))
|
||||
print(f"Mapped {len(by_book_page)} book pages; {missed_count} inferred; {len(unmapped)} unmapped")
|
||||
print(f"Wrote {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply high-confidence auto-fixes from vocab_validation.json to vocab_cards.json.
|
||||
|
||||
Auto-fix rules (conservative):
|
||||
1. If a flagged word has exactly one suggestion AND that suggestion differs by
|
||||
<= 2 characters AND has the same starting letter (high-confidence character swap).
|
||||
2. If a card is detected as reversed (Spanish on EN side, English on ES side),
|
||||
swap front/back.
|
||||
|
||||
Cards that aren't auto-fixable end up in manual_review.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
VOCAB = HERE / "vocab_cards.json"
|
||||
VALIDATION = HERE / "vocab_validation.json"
|
||||
OUT_VOCAB = HERE / "vocab_cards.json"
|
||||
OUT_REVIEW = HERE / "manual_review.json"
|
||||
OUT_QUARANTINE = HERE / "quarantined_cards.json"
|
||||
|
||||
|
||||
def _strip_accents(s: str) -> str:
|
||||
return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn")
|
||||
|
||||
|
||||
def _levenshtein(a: str, b: str) -> int:
|
||||
if a == b: return 0
|
||||
if not a: return len(b)
|
||||
if not b: return len(a)
|
||||
prev = list(range(len(b) + 1))
|
||||
for i, ca in enumerate(a, 1):
|
||||
curr = [i]
|
||||
for j, cb in enumerate(b, 1):
|
||||
cost = 0 if ca == cb else 1
|
||||
curr.append(min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost))
|
||||
prev = curr
|
||||
return prev[-1]
|
||||
|
||||
|
||||
SPANISH_ACCENT_RE = re.compile(r"[áéíóúñüÁÉÍÓÚÑÜ¿¡]")
|
||||
SPANISH_ARTICLES = {"el", "la", "los", "las", "un", "una", "unos", "unas"}
|
||||
ENGLISH_STARTERS = {"the", "a", "an", "to", "my", "his", "her", "our", "their"}
|
||||
|
||||
|
||||
HABER_FORMS = {"he", "has", "ha", "hemos", "habéis", "han"}
|
||||
|
||||
|
||||
def language_score(s: str) -> "tuple[int, int]":
|
||||
"""Return (es_score, en_score) for a string."""
|
||||
es = 0
|
||||
en = 0
|
||||
if SPANISH_ACCENT_RE.search(s):
|
||||
es += 3
|
||||
words = s.lower().split()
|
||||
if not words:
|
||||
return (es, en)
|
||||
first = words[0].strip(",.;:")
|
||||
second = words[1].strip(",.;:") if len(words) > 1 else ""
|
||||
# Spanish present-perfect ("he tenido", "Ha andado") starts with a haber
|
||||
# form followed by an -ado/-ido past participle. Recognise this pattern
|
||||
# before the bare-pronoun check so "he" isn't mistaken for English "he".
|
||||
if first in HABER_FORMS and (
|
||||
second.endswith(("ado", "ido", "to", "cho", "sto", "esto"))
|
||||
):
|
||||
es += 3
|
||||
elif first in SPANISH_ARTICLES:
|
||||
es += 2
|
||||
elif first in ENGLISH_STARTERS:
|
||||
en += 2
|
||||
# Spanish-likely endings on later words
|
||||
for w in words:
|
||||
w = w.strip(",.;:")
|
||||
if not w: continue
|
||||
if w.endswith(("ción", "sión", "dad", "tud")):
|
||||
es += 1
|
||||
if w.endswith(("ing", "tion", "ness", "ment", "able", "ly")):
|
||||
en += 1
|
||||
return (es, en)
|
||||
|
||||
|
||||
def is_reversed(front: str, back: str) -> bool:
|
||||
"""True when front looks like English and back looks like Spanish (i.e. swapped)."""
|
||||
fes, fen = language_score(front)
|
||||
bes, ben = language_score(back)
|
||||
# Front English-leaning AND back Spanish-leaning
|
||||
return fen > fes and bes > ben
|
||||
|
||||
|
||||
def best_replacement(word: str, suggestions: list) -> "str | None":
|
||||
"""Pick the one safe correction, or None to leave it alone."""
|
||||
if not suggestions:
|
||||
return None
|
||||
# Prefer suggestions that share the same first letter
|
||||
same_initial = [s for s in suggestions if s and word and s[0].lower() == word[0].lower()]
|
||||
candidates = same_initial or suggestions
|
||||
# Single best: short edit distance
|
||||
best = None
|
||||
best_d = 99
|
||||
for s in candidates:
|
||||
d = _levenshtein(word.lower(), s.lower())
|
||||
# Don't apply if the "fix" changes too much
|
||||
if d == 0:
|
||||
continue
|
||||
if d > 2:
|
||||
continue
|
||||
if d < best_d:
|
||||
best = s
|
||||
best_d = d
|
||||
return best
|
||||
|
||||
|
||||
def side_language_match(text: str, expected_side: str) -> bool:
|
||||
"""Return True when `text` looks like the expected language (es/en).
|
||||
Guards against applying Spanish spell-fix to English words on a mis-paired card.
|
||||
"""
|
||||
es, en = language_score(text)
|
||||
if expected_side == "es":
|
||||
return es > en # require clear Spanish signal
|
||||
if expected_side == "en":
|
||||
return en >= es # allow equal when text has no strong signal (common for English)
|
||||
return False
|
||||
|
||||
|
||||
def apply_word_fixes(text: str, bad_words: list, expected_side: str) -> "tuple[str, list]":
|
||||
"""Apply word-level corrections inside a string. Skips fixes entirely when
|
||||
the side's actual language doesn't match the dictionary used, to avoid
|
||||
corrupting mis-paired cards."""
|
||||
if not side_language_match(text, expected_side):
|
||||
return (text, [])
|
||||
|
||||
new_text = text
|
||||
applied = []
|
||||
for bw in bad_words:
|
||||
word = bw["word"]
|
||||
sugg = bw["suggestions"]
|
||||
replacement = best_replacement(word, sugg)
|
||||
if replacement is None:
|
||||
continue
|
||||
# Match standalone word including the (possibly-omitted) trailing period:
|
||||
# `Uds` in the text should be replaced with `Uds.` even when adjacent to `.`.
|
||||
escaped = re.escape(word)
|
||||
# Allow an optional existing period that we'd otherwise duplicate.
|
||||
pattern = re.compile(rf"(?<![A-Za-zÁ-ú]){escaped}\.?(?![A-Za-zÁ-ú])")
|
||||
if pattern.search(new_text):
|
||||
new_text = pattern.sub(replacement, new_text, count=1)
|
||||
applied.append({"from": word, "to": replacement})
|
||||
return (new_text, applied)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
vocab_data = json.loads(VOCAB.read_text(encoding="utf-8"))
|
||||
val_data = json.loads(VALIDATION.read_text(encoding="utf-8"))
|
||||
|
||||
# Index validation by (chapter, front, back, sourceImage) for lookup
|
||||
val_index: dict = {}
|
||||
for f in val_data["flags"]:
|
||||
key = (f["chapter"], f["front"], f["back"], f["sourceImage"])
|
||||
val_index[key] = f
|
||||
|
||||
# Walk the cards in place
|
||||
auto_fixed_word = 0
|
||||
auto_swapped = 0
|
||||
quarantined = 0
|
||||
manual_review_cards = []
|
||||
quarantined_cards = []
|
||||
|
||||
for ch in vocab_data["chapters"]:
|
||||
kept_cards = []
|
||||
for card in ch["cards"]:
|
||||
key = (ch["chapter"], card["front"], card["back"], card.get("sourceImage", ""))
|
||||
flag = val_index.get(key)
|
||||
|
||||
# 1) Reversal swap (apply even when not flagged)
|
||||
if is_reversed(card["front"], card["back"]):
|
||||
card["front"], card["back"] = card["back"], card["front"]
|
||||
auto_swapped += 1
|
||||
# Re-key for any further validation lookup (no-op here)
|
||||
|
||||
if flag is None:
|
||||
kept_cards.append(card)
|
||||
continue
|
||||
|
||||
# Quarantine only clear mis-pairs: both sides EXPLICITLY the wrong
|
||||
# language (both Spanish or both English). "unknown" sides stay —
|
||||
# the bounding-box pipeline already handled orientation correctly
|
||||
# and many valid pairs lack the article/accent markers we classify on.
|
||||
fes, fen = language_score(card["front"])
|
||||
bes, ben = language_score(card["back"])
|
||||
front_lang = "es" if fes > fen else ("en" if fen > fes else "unknown")
|
||||
back_lang = "es" if bes > ben else ("en" if ben > bes else "unknown")
|
||||
bothSameLang = (front_lang == "es" and back_lang == "es") or (front_lang == "en" and back_lang == "en")
|
||||
reversed_pair = front_lang == "en" and back_lang == "es"
|
||||
if bothSameLang or reversed_pair:
|
||||
quarantined_cards.append({
|
||||
"chapter": ch["chapter"],
|
||||
"front": card["front"],
|
||||
"back": card["back"],
|
||||
"sourceImage": card.get("sourceImage", ""),
|
||||
"reason": f"language-mismatch front={front_lang} back={back_lang}",
|
||||
})
|
||||
quarantined += 1
|
||||
continue
|
||||
|
||||
# 2) Word-level fixes (language-aware)
|
||||
new_front, applied_front = apply_word_fixes(card["front"], flag["badFront"], "es")
|
||||
new_back, applied_back = apply_word_fixes(card["back"], flag["badBack"], "en")
|
||||
card["front"] = new_front
|
||||
card["back"] = new_back
|
||||
auto_fixed_word += len(applied_front) + len(applied_back)
|
||||
|
||||
# If after auto-fix there are STILL flagged words with no
|
||||
# confident replacement, flag for manual review.
|
||||
unresolved_front = [
|
||||
bw for bw in flag["badFront"]
|
||||
if not any(a["from"] == bw["word"] for a in applied_front)
|
||||
and best_replacement(bw["word"], bw["suggestions"]) is None
|
||||
]
|
||||
unresolved_back = [
|
||||
bw for bw in flag["badBack"]
|
||||
if not any(a["from"] == bw["word"] for a in applied_back)
|
||||
and best_replacement(bw["word"], bw["suggestions"]) is None
|
||||
]
|
||||
if unresolved_front or unresolved_back:
|
||||
manual_review_cards.append({
|
||||
"chapter": ch["chapter"],
|
||||
"front": card["front"],
|
||||
"back": card["back"],
|
||||
"sourceImage": card.get("sourceImage", ""),
|
||||
"unresolvedFront": unresolved_front,
|
||||
"unresolvedBack": unresolved_back,
|
||||
})
|
||||
kept_cards.append(card)
|
||||
|
||||
ch["cards"] = kept_cards
|
||||
|
||||
OUT_VOCAB.write_text(json.dumps(vocab_data, ensure_ascii=False, indent=2))
|
||||
OUT_REVIEW.write_text(json.dumps({
|
||||
"totalManualReview": len(manual_review_cards),
|
||||
"cards": manual_review_cards,
|
||||
}, ensure_ascii=False, indent=2))
|
||||
|
||||
OUT_QUARANTINE.write_text(json.dumps({
|
||||
"totalQuarantined": len(quarantined_cards),
|
||||
"cards": quarantined_cards,
|
||||
}, ensure_ascii=False, indent=2))
|
||||
|
||||
total_cards = sum(len(c["cards"]) for c in vocab_data["chapters"])
|
||||
print(f"Active cards (after quarantine): {total_cards}")
|
||||
print(f"Auto-swapped (reversed): {auto_swapped}")
|
||||
print(f"Auto-fixed words: {auto_fixed_word}")
|
||||
print(f"Quarantined (mis-paired): {quarantined}")
|
||||
print(f"Cards needing manual review: {len(manual_review_cards)}")
|
||||
print(f"Wrote {OUT_VOCAB}")
|
||||
print(f"Wrote {OUT_REVIEW}")
|
||||
print(f"Wrote {OUT_QUARANTINE}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merge repaired_cards.json into vocab_cards.json.
|
||||
|
||||
Rules:
|
||||
1. New pairs are added to their chapter's deck if they don't duplicate an existing pair.
|
||||
2. Duplicate detection uses normalize(front)+normalize(back).
|
||||
3. Pairs whose back side starts with a Spanish-article or front side starts
|
||||
with an English article are dropped (pairer got orientation wrong).
|
||||
4. Emits integrate_report.json with counts.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
VOCAB = HERE / "vocab_cards.json"
|
||||
REPAIRED = HERE / "repaired_cards.json"
|
||||
QUARANTINED = HERE / "quarantined_cards.json"
|
||||
OUT = HERE / "vocab_cards.json"
|
||||
REPORT = HERE / "integrate_report.json"
|
||||
|
||||
|
||||
def _strip_accents(s: str) -> str:
|
||||
return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn")
|
||||
|
||||
|
||||
def norm(s: str) -> str:
|
||||
return _strip_accents(s.lower()).strip()
|
||||
|
||||
|
||||
SPANISH_ACCENT_RE = re.compile(r"[áéíóúñüÁÉÍÓÚÑÜ¿¡]")
|
||||
SPANISH_ARTICLES = {"el", "la", "los", "las", "un", "una", "unos", "unas"}
|
||||
ENGLISH_STARTERS = {"the", "a", "an", "to", "my", "his", "her", "our", "their"}
|
||||
|
||||
|
||||
def looks_swapped(front: str, back: str) -> bool:
|
||||
"""True if front looks English and back looks Spanish (pair should be swapped)."""
|
||||
fl = front.lower().split()
|
||||
bl = back.lower().split()
|
||||
if not fl or not bl:
|
||||
return False
|
||||
f_first = fl[0].strip(",.;:")
|
||||
b_first = bl[0].strip(",.;:")
|
||||
front_is_en = f_first in ENGLISH_STARTERS
|
||||
back_is_es = (
|
||||
SPANISH_ACCENT_RE.search(back) is not None
|
||||
or b_first in SPANISH_ARTICLES
|
||||
)
|
||||
return front_is_en and back_is_es
|
||||
|
||||
|
||||
def looks_good(pair: dict) -> bool:
|
||||
"""Basic sanity filter on a repaired pair before it enters the deck."""
|
||||
es = pair["es"].strip()
|
||||
en = pair["en"].strip()
|
||||
if not es or not en: return False
|
||||
if len(es) < 2 or len(en) < 2: return False
|
||||
# Drop if both sides obviously same language (neither has clear orientation)
|
||||
es_has_accent = SPANISH_ACCENT_RE.search(es) is not None
|
||||
en_has_accent = SPANISH_ACCENT_RE.search(en) is not None
|
||||
if en_has_accent and not es_has_accent:
|
||||
# The "en" side has accents — likely swapped
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def main() -> None:
|
||||
vocab = json.loads(VOCAB.read_text(encoding="utf-8"))
|
||||
repaired = json.loads(REPAIRED.read_text(encoding="utf-8"))
|
||||
quarantined = json.loads(QUARANTINED.read_text(encoding="utf-8"))
|
||||
|
||||
# Map image → chapter (from the quarantine list — all images here belong to the
|
||||
# chapter they were quarantined from).
|
||||
image_chapter: dict = {}
|
||||
for c in quarantined["cards"]:
|
||||
image_chapter[c["sourceImage"]] = c["chapter"]
|
||||
|
||||
# Build existing key set
|
||||
existing_keys = set()
|
||||
chapter_map: dict = {c["chapter"]: c for c in vocab["chapters"]}
|
||||
for c in vocab["chapters"]:
|
||||
for card in c["cards"]:
|
||||
existing_keys.add((c["chapter"], norm(card["front"]), norm(card["back"])))
|
||||
|
||||
added_per_image: dict = {}
|
||||
dropped_swapped = 0
|
||||
dropped_sanity = 0
|
||||
dropped_dup = 0
|
||||
|
||||
for image_name, data in repaired["byImage"].items():
|
||||
ch_num = image_chapter.get(image_name)
|
||||
if ch_num is None:
|
||||
# Image not in quarantine list (shouldn't happen, but bail)
|
||||
continue
|
||||
deck = chapter_map.setdefault(ch_num, {"chapter": ch_num, "cards": []})
|
||||
added = 0
|
||||
for p in data.get("pairs", []):
|
||||
es = p["es"].strip()
|
||||
en = p["en"].strip()
|
||||
if looks_swapped(es, en):
|
||||
es, en = en, es
|
||||
pair = {"es": es, "en": en}
|
||||
if not looks_good(pair):
|
||||
dropped_sanity += 1
|
||||
continue
|
||||
key = (ch_num, norm(pair["es"]), norm(pair["en"]))
|
||||
if key in existing_keys:
|
||||
dropped_dup += 1
|
||||
continue
|
||||
existing_keys.add(key)
|
||||
card = {
|
||||
"front": pair["es"],
|
||||
"back": pair["en"],
|
||||
"chapter": ch_num,
|
||||
"chapterTitle": "",
|
||||
"section": "",
|
||||
"sourceImage": image_name,
|
||||
}
|
||||
deck["cards"].append(card)
|
||||
added += 1
|
||||
if added:
|
||||
added_per_image[image_name] = added
|
||||
|
||||
# If any new chapter was created, ensure ordered insertion
|
||||
vocab["chapters"] = sorted(chapter_map.values(), key=lambda c: c["chapter"])
|
||||
OUT.write_text(json.dumps(vocab, ensure_ascii=False, indent=2))
|
||||
|
||||
total_added = sum(added_per_image.values())
|
||||
report = {
|
||||
"totalRepairedInput": repaired["totalPairs"],
|
||||
"added": total_added,
|
||||
"dropped_duplicate": dropped_dup,
|
||||
"dropped_sanity": dropped_sanity,
|
||||
"addedPerImage": added_per_image,
|
||||
}
|
||||
REPORT.write_text(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
print(f"Repaired pairs in: {repaired['totalPairs']}")
|
||||
print(f"Added to deck: {total_added}")
|
||||
print(f"Dropped as duplicate: {dropped_dup}")
|
||||
print(f"Dropped as swapped/bad: {dropped_sanity}")
|
||||
print(f"Wrote {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,471 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Second-pass extractor: use PDF OCR (from ocr_pdf.swift) as a supplementary
|
||||
source of clean text, then re-build book.json with PDF-derived content where it
|
||||
improves on the EPUB's image-based extraction.
|
||||
|
||||
Inputs:
|
||||
chapters.json — EPUB structural extraction (narrative text + exercise prompts + image refs)
|
||||
answers.json — EPUB answer key
|
||||
ocr.json — EPUB image OCR (first pass)
|
||||
pdf_ocr.json — PDF page-level OCR (this pass, higher DPI + cleaner)
|
||||
|
||||
Outputs:
|
||||
book.json — merged book used by the app
|
||||
vocab_cards.json — derived vocabulary flashcards
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
from build_book import ( # reuse the helpers defined in build_book.py
|
||||
COURSE_NAME,
|
||||
build_vocab_cards_for_block,
|
||||
clean_instruction,
|
||||
classify_line,
|
||||
load,
|
||||
)
|
||||
|
||||
CHAPTERS_JSON = HERE / "chapters.json"
|
||||
ANSWERS_JSON = HERE / "answers.json"
|
||||
OCR_JSON = HERE / "ocr.json"
|
||||
PDF_OCR_JSON = HERE / "pdf_ocr.json"
|
||||
PAIRED_VOCAB_JSON = HERE / "paired_vocab.json" # bounding-box pairs (fallback)
|
||||
PAIRED_VOCAB_LLM_JSON = HERE / "paired_vocab_llm.json" # LLM vision pairs (preferred)
|
||||
OUT_BOOK = HERE / "book.json"
|
||||
OUT_VOCAB = HERE / "vocab_cards.json"
|
||||
|
||||
IMAGE_NAME_RE = re.compile(r"^f(\d{4})-(\d{2})\.jpg$")
|
||||
|
||||
|
||||
def extract_book_page(image_src: str) -> "int | None":
|
||||
m = IMAGE_NAME_RE.match(image_src)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def build_pdf_page_index(pdf_ocr: dict) -> "dict[int, dict]":
|
||||
"""Map bookPage → {lines, confidence, pdfIndex}.
|
||||
|
||||
Strategy: use chapter-start alignments as anchors. For each chapter N,
|
||||
anchor[N] = (pdf_idx_where_chapter_starts, book_page_where_chapter_starts).
|
||||
Between anchors we interpolate page-by-page (pages run sequentially within
|
||||
a chapter in this textbook's layout).
|
||||
"""
|
||||
pages: "dict[int, dict]" = {}
|
||||
sorted_keys = sorted(pdf_ocr.keys(), key=lambda k: int(k))
|
||||
|
||||
# --- Detect chapter starts in the PDF OCR ---
|
||||
pdf_ch_start: "dict[int, int]" = {}
|
||||
for k in sorted_keys:
|
||||
entry = pdf_ocr[k]
|
||||
lines = entry.get("lines", [])
|
||||
if len(lines) < 2:
|
||||
continue
|
||||
first = lines[0].strip()
|
||||
second = lines[1].strip()
|
||||
if first.isdigit() and 1 <= int(first) <= 30 and len(second) > 5 and second[0:1].isupper():
|
||||
ch = int(first)
|
||||
if ch not in pdf_ch_start:
|
||||
pdf_ch_start[ch] = int(k)
|
||||
|
||||
# --- Load EPUB's authoritative book-page starts ---
|
||||
import re as _re
|
||||
from bs4 import BeautifulSoup as _BS
|
||||
epub_root = HERE.parents[2] / "epub_extract" / "OEBPS"
|
||||
book_ch_start: "dict[int, int]" = {}
|
||||
for ch in sorted(pdf_ch_start.keys()):
|
||||
p = epub_root / f"ch{ch}.xhtml"
|
||||
if not p.exists():
|
||||
continue
|
||||
soup = _BS(p.read_text(encoding="utf-8"), "lxml")
|
||||
for span in soup.find_all(True):
|
||||
id_ = span.get("id", "") or ""
|
||||
m = _re.match(r"page_(\d+)$", id_)
|
||||
if m:
|
||||
book_ch_start[ch] = int(m.group(1))
|
||||
break
|
||||
|
||||
# Build per-chapter (pdf_anchor, book_anchor, next_pdf_anchor) intervals
|
||||
anchors = [] # list of (ch, pdf_start, book_start)
|
||||
for ch in sorted(pdf_ch_start.keys()):
|
||||
if ch in book_ch_start:
|
||||
anchors.append((ch, pdf_ch_start[ch], book_ch_start[ch]))
|
||||
|
||||
for i, (ch, pdf_s, book_s) in enumerate(anchors):
|
||||
next_pdf = anchors[i + 1][1] if i + 1 < len(anchors) else pdf_s + 50
|
||||
# Interpolate book page for each pdf index in [pdf_s, next_pdf)
|
||||
for pdf_idx in range(pdf_s, next_pdf):
|
||||
book_page = book_s + (pdf_idx - pdf_s)
|
||||
entry = pdf_ocr.get(str(pdf_idx))
|
||||
if entry is None:
|
||||
continue
|
||||
if book_page in pages:
|
||||
continue
|
||||
pages[book_page] = {
|
||||
"lines": entry["lines"],
|
||||
"confidence": entry.get("confidence", 0),
|
||||
"pdfIndex": pdf_idx,
|
||||
}
|
||||
return pages
|
||||
|
||||
|
||||
def merge_ocr(epub_lines: list, pdf_lines: list) -> list:
|
||||
"""EPUB per-image OCR is our primary (targeted, no prose bleed). PDF
|
||||
page-level OCR is only used when EPUB is missing. Per-line accent repair
|
||||
is handled separately via `repair_accents_from_pdf`.
|
||||
"""
|
||||
if epub_lines:
|
||||
return epub_lines
|
||||
return pdf_lines
|
||||
|
||||
|
||||
import unicodedata as _u
|
||||
|
||||
def _strip_accents(s: str) -> str:
|
||||
return "".join(c for c in _u.normalize("NFD", s) if _u.category(c) != "Mn")
|
||||
|
||||
|
||||
def _levenshtein(a: str, b: str) -> int:
|
||||
if a == b: return 0
|
||||
if not a: return len(b)
|
||||
if not b: return len(a)
|
||||
prev = list(range(len(b) + 1))
|
||||
for i, ca in enumerate(a, 1):
|
||||
curr = [i]
|
||||
for j, cb in enumerate(b, 1):
|
||||
cost = 0 if ca == cb else 1
|
||||
curr.append(min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost))
|
||||
prev = curr
|
||||
return prev[-1]
|
||||
|
||||
|
||||
def repair_accents_from_pdf(epub_lines: list, pdf_page_lines: list) -> "tuple[list, int]":
|
||||
"""For each EPUB OCR line, find a near-match in the PDF page OCR and
|
||||
prefer the PDF version. Repairs include:
|
||||
1. exact accent/case differences (e.g. 'iglesia' vs 'Iglesia')
|
||||
2. single-character OCR errors (e.g. 'the hrother' -> 'the brother')
|
||||
3. two-character OCR errors when the target is long enough
|
||||
"""
|
||||
if not epub_lines or not pdf_page_lines:
|
||||
return (epub_lines, 0)
|
||||
# Pre-normalize PDF lines for matching
|
||||
pdf_cleaned = [p.strip() for p in pdf_page_lines if p.strip()]
|
||||
pdf_by_stripped: dict = {}
|
||||
for p in pdf_cleaned:
|
||||
key = _strip_accents(p.lower())
|
||||
pdf_by_stripped.setdefault(key, p)
|
||||
|
||||
out: list = []
|
||||
repairs = 0
|
||||
for e in epub_lines:
|
||||
e_stripped = e.strip()
|
||||
e_key = _strip_accents(e_stripped.lower())
|
||||
# Pass 1: exact accent-only difference
|
||||
if e_key and e_key in pdf_by_stripped and pdf_by_stripped[e_key] != e_stripped:
|
||||
out.append(pdf_by_stripped[e_key])
|
||||
repairs += 1
|
||||
continue
|
||||
# Pass 2: fuzzy — find best PDF line within edit distance 1 or 2
|
||||
if len(e_key) >= 4:
|
||||
max_distance = 1 if len(e_key) < 10 else 2
|
||||
best_match = None
|
||||
best_d = max_distance + 1
|
||||
for p in pdf_cleaned:
|
||||
p_key = _strip_accents(p.lower())
|
||||
# Only match lines of similar length
|
||||
if abs(len(p_key) - len(e_key)) > max_distance:
|
||||
continue
|
||||
d = _levenshtein(e_key, p_key)
|
||||
if d < best_d:
|
||||
best_d = d
|
||||
best_match = p
|
||||
if d == 0:
|
||||
break
|
||||
if best_match and best_match != e_stripped and best_d <= max_distance:
|
||||
out.append(best_match)
|
||||
repairs += 1
|
||||
continue
|
||||
out.append(e)
|
||||
return (out, repairs)
|
||||
|
||||
|
||||
def vocab_lines_from_pdf_page(
|
||||
pdf_page_entry: dict,
|
||||
epub_narrative_lines: set
|
||||
) -> list:
|
||||
"""Extract likely vocab-table lines from a PDF page's OCR by filtering out
|
||||
narrative-looking lines (long sentences) and already-known EPUB content."""
|
||||
lines = pdf_page_entry.get("lines", [])
|
||||
out: list = []
|
||||
for raw in lines:
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
# Skip lines that look like body prose (too long)
|
||||
if len(line) > 80:
|
||||
continue
|
||||
# Skip narrative we already captured in the EPUB
|
||||
if line in epub_narrative_lines:
|
||||
continue
|
||||
# Skip page-number-only lines
|
||||
if re.fullmatch(r"\d{1,4}", line):
|
||||
continue
|
||||
# Skip standalone chapter headers (e.g. "Nouns, Articles, and Adjectives")
|
||||
out.append(line)
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
chapters_data = load(CHAPTERS_JSON)
|
||||
answers = load(ANSWERS_JSON)["answers"]
|
||||
epub_ocr = load(OCR_JSON)
|
||||
pdf_ocr_raw = load(PDF_OCR_JSON) if PDF_OCR_JSON.exists() else {}
|
||||
pdf_pages = build_pdf_page_index(pdf_ocr_raw) if pdf_ocr_raw else {}
|
||||
paired_vocab = load(PAIRED_VOCAB_JSON) if PAIRED_VOCAB_JSON.exists() else {}
|
||||
paired_llm = load(PAIRED_VOCAB_LLM_JSON) if PAIRED_VOCAB_LLM_JSON.exists() else {}
|
||||
print(f"Mapped {len(pdf_pages)} PDF pages to book page numbers")
|
||||
print(f"Loaded bounding-box pairs for {len(paired_vocab)} vocab images")
|
||||
print(f"Loaded LLM-vision pairs for {len(paired_llm)} vocab images")
|
||||
|
||||
# Build a global set of EPUB narrative lines (for subtraction when pulling vocab)
|
||||
narrative_set = set()
|
||||
for ch in chapters_data["chapters"]:
|
||||
for b in ch["blocks"]:
|
||||
if b["kind"] == "paragraph" and b.get("text"):
|
||||
narrative_set.add(b["text"].strip())
|
||||
|
||||
book_chapters = []
|
||||
all_vocab_cards = []
|
||||
pdf_hits = 0
|
||||
pdf_misses = 0
|
||||
merged_pages = 0
|
||||
|
||||
for ch in chapters_data["chapters"]:
|
||||
out_blocks = []
|
||||
current_section_title = ch["title"]
|
||||
|
||||
for bi, block in enumerate(ch["blocks"]):
|
||||
k = block["kind"]
|
||||
|
||||
if k == "heading":
|
||||
current_section_title = block["text"]
|
||||
out_blocks.append(block)
|
||||
continue
|
||||
|
||||
if k == "paragraph":
|
||||
out_blocks.append(block)
|
||||
continue
|
||||
|
||||
if k == "key_vocab_header":
|
||||
out_blocks.append(block)
|
||||
continue
|
||||
|
||||
if k == "vocab_image":
|
||||
src = block["src"]
|
||||
epub_entry = epub_ocr.get(src)
|
||||
epub_lines = epub_entry.get("lines", []) if epub_entry else []
|
||||
epub_conf = epub_entry.get("confidence", 0.0) if epub_entry else 0.0
|
||||
|
||||
book_page = extract_book_page(src)
|
||||
pdf_entry = pdf_pages.get(book_page) if book_page else None
|
||||
pdf_lines = pdf_entry["lines"] if pdf_entry else []
|
||||
|
||||
# Primary: EPUB per-image OCR. Supplementary: PDF page OCR
|
||||
# used only for accent/diacritic repair where keys match.
|
||||
if pdf_lines:
|
||||
pdf_hits += 1
|
||||
else:
|
||||
pdf_misses += 1
|
||||
repaired_lines, repairs = repair_accents_from_pdf(epub_lines, pdf_lines)
|
||||
merged_lines = repaired_lines if repaired_lines else pdf_lines
|
||||
merged_conf = max(epub_conf, pdf_entry.get("confidence", 0) if pdf_entry else 0.0)
|
||||
if repairs > 0:
|
||||
merged_pages += 1
|
||||
|
||||
# Source priority:
|
||||
# 1) LLM-vision pairs (paired_vocab_llm.json) — semantic
|
||||
# classification (pair_table / reference_only / hybrid)
|
||||
# with correct orientation.
|
||||
# 2) Bounding-box pairs (paired_vocab.json) — Vision OCR
|
||||
# with X-gap row splitting.
|
||||
# 3) Block-alternation heuristic — flat OCR fallback.
|
||||
llm_entry = paired_llm.get(src, {}) if isinstance(paired_llm.get(src), dict) else {}
|
||||
llm_kind = llm_entry.get("kind")
|
||||
llm_pairs = llm_entry.get("pairs", []) if llm_entry else []
|
||||
|
||||
bbox = paired_vocab.get(src, {})
|
||||
bbox_pairs = bbox.get("pairs", []) if isinstance(bbox, dict) else []
|
||||
|
||||
heuristic = build_vocab_cards_for_block(
|
||||
{"src": src},
|
||||
{"lines": merged_lines, "confidence": merged_conf},
|
||||
ch, current_section_title, bi
|
||||
)
|
||||
|
||||
# Choose pair source. For reference_only (Spanish-only tables)
|
||||
# we deliberately produce no cards — the UI will fall back to
|
||||
# rendering the flat OCR lines as a reference list. Same for
|
||||
# hybrid images where the LLM determined no genuine pair rows
|
||||
# exist (e.g. estar conjugations with English glosses on the
|
||||
# header row only).
|
||||
if llm_kind == "reference_only" or (llm_kind == "hybrid" and not llm_pairs):
|
||||
cards_for_block = []
|
||||
pair_source = "llm-no-pairs"
|
||||
elif llm_pairs:
|
||||
cards_for_block = [
|
||||
{"front": p["es"], "back": p["en"]}
|
||||
for p in llm_pairs
|
||||
if p.get("es") and p.get("en")
|
||||
]
|
||||
for c in cards_for_block:
|
||||
all_vocab_cards.append({
|
||||
"front": c["front"], "back": c["back"],
|
||||
"chapter": ch["number"],
|
||||
"chapterTitle": ch["title"],
|
||||
"section": current_section_title,
|
||||
"sourceImage": src,
|
||||
})
|
||||
pair_source = "llm-" + (llm_kind or "pairs")
|
||||
elif bbox_pairs:
|
||||
cards_for_block = [
|
||||
{"front": p["es"], "back": p["en"]}
|
||||
for p in bbox_pairs
|
||||
if p.get("es") and p.get("en")
|
||||
]
|
||||
for p in bbox_pairs:
|
||||
if p.get("es") and p.get("en"):
|
||||
all_vocab_cards.append({
|
||||
"front": p["es"], "back": p["en"],
|
||||
"chapter": ch["number"],
|
||||
"chapterTitle": ch["title"],
|
||||
"section": current_section_title,
|
||||
"sourceImage": src,
|
||||
})
|
||||
pair_source = "bbox"
|
||||
else:
|
||||
cards_for_block = [{"front": c["front"], "back": c["back"]} for c in heuristic]
|
||||
all_vocab_cards.extend(heuristic)
|
||||
pair_source = "heuristic"
|
||||
|
||||
out_blocks.append({
|
||||
"kind": "vocab_table",
|
||||
"sourceImage": src,
|
||||
"ocrLines": merged_lines,
|
||||
"ocrConfidence": merged_conf,
|
||||
"cardCount": len(cards_for_block),
|
||||
"cards": cards_for_block,
|
||||
"columnCount": bbox.get("columnCount", 2) if isinstance(bbox, dict) else 2,
|
||||
"source": pair_source,
|
||||
"bookPage": book_page,
|
||||
"repairs": repairs,
|
||||
"tableKind": llm_kind,
|
||||
})
|
||||
continue
|
||||
|
||||
if k == "exercise":
|
||||
ans = answers.get(block["id"])
|
||||
# EPUB image OCR (if any image refs)
|
||||
image_ocr_lines: list = []
|
||||
for src in block.get("image_refs", []):
|
||||
ee = epub_ocr.get(src)
|
||||
if ee:
|
||||
image_ocr_lines.extend(ee.get("lines", []))
|
||||
# Add PDF-page OCR for that page if available
|
||||
bp = extract_book_page(src)
|
||||
if bp and pdf_pages.get(bp):
|
||||
# Only add lines not already present from EPUB OCR
|
||||
pdf_lines = pdf_pages[bp]["lines"]
|
||||
for line in pdf_lines:
|
||||
line = line.strip()
|
||||
if not line or line in image_ocr_lines:
|
||||
continue
|
||||
if line in narrative_set:
|
||||
continue
|
||||
image_ocr_lines.append(line)
|
||||
|
||||
prompts = [p for p in block.get("prompts", []) if p.strip()]
|
||||
extras = [e for e in block.get("extra", []) if e.strip()]
|
||||
if not prompts and image_ocr_lines:
|
||||
# Extract numbered lines from OCR
|
||||
for line in image_ocr_lines:
|
||||
m = re.match(r"^(\d+)[.)]\s*(.+)", line.strip())
|
||||
if m:
|
||||
prompts.append(f"{m.group(1)}. {m.group(2)}")
|
||||
|
||||
sub = ans["subparts"] if ans else []
|
||||
answer_items = []
|
||||
for sp in sub:
|
||||
for it in sp["items"]:
|
||||
answer_items.append({
|
||||
"label": sp["label"],
|
||||
"number": it["number"],
|
||||
"answer": it["answer"],
|
||||
"alternates": it["alternates"],
|
||||
})
|
||||
|
||||
out_blocks.append({
|
||||
"kind": "exercise",
|
||||
"id": block["id"],
|
||||
"ansAnchor": block.get("ans_anchor", ""),
|
||||
"instruction": clean_instruction(block.get("instruction", "")),
|
||||
"extra": extras,
|
||||
"prompts": prompts,
|
||||
"ocrLines": image_ocr_lines,
|
||||
"freeform": ans["freeform"] if ans else False,
|
||||
"answerItems": answer_items,
|
||||
"answerRaw": ans["raw"] if ans else "",
|
||||
"answerSubparts": sub,
|
||||
})
|
||||
continue
|
||||
|
||||
out_blocks.append(block)
|
||||
|
||||
book_chapters.append({
|
||||
"id": ch["id"],
|
||||
"number": ch["number"],
|
||||
"title": ch["title"],
|
||||
"part": ch.get("part"),
|
||||
"blocks": out_blocks,
|
||||
})
|
||||
|
||||
book = {
|
||||
"courseName": COURSE_NAME,
|
||||
"totalChapters": len(book_chapters),
|
||||
"totalExercises": sum(1 for ch in book_chapters for b in ch["blocks"] if b["kind"] == "exercise"),
|
||||
"totalVocabTables": sum(1 for ch in book_chapters for b in ch["blocks"] if b["kind"] == "vocab_table"),
|
||||
"totalVocabCards": len(all_vocab_cards),
|
||||
"parts": chapters_data.get("part_memberships", {}),
|
||||
"chapters": book_chapters,
|
||||
"sources": {
|
||||
"epub_images_ocr": bool(epub_ocr),
|
||||
"pdf_pages_ocr": bool(pdf_ocr_raw),
|
||||
"pdf_pages_mapped": len(pdf_pages),
|
||||
},
|
||||
}
|
||||
OUT_BOOK.write_text(json.dumps(book, ensure_ascii=False))
|
||||
|
||||
vocab_by_chapter: dict = {}
|
||||
for card in all_vocab_cards:
|
||||
vocab_by_chapter.setdefault(card["chapter"], []).append(card)
|
||||
OUT_VOCAB.write_text(json.dumps({
|
||||
"courseName": COURSE_NAME,
|
||||
"chapters": [
|
||||
{"chapter": n, "cards": cs}
|
||||
for n, cs in sorted(vocab_by_chapter.items())
|
||||
],
|
||||
}, ensure_ascii=False, indent=2))
|
||||
|
||||
print(f"Wrote {OUT_BOOK}")
|
||||
print(f"Wrote {OUT_VOCAB}")
|
||||
print(f"Chapters: {book['totalChapters']}")
|
||||
print(f"Exercises: {book['totalExercises']}")
|
||||
print(f"Vocab tables: {book['totalVocabTables']}")
|
||||
print(f"Vocab cards (derived): {book['totalVocabCards']}")
|
||||
print(f"PDF hits vs misses: {pdf_hits} / {pdf_misses}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user